From 44fb7cfe38c876bff189a7ca9f6e84e85f1966ce Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 27 Apr 2012 14:04:08 -0400 Subject: [PATCH 001/234] Update dev version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6e1c7c71..3738b25e 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ setup( name='Flask-Security', - version='1.2.2-dev', + version='1.2.3-dev', url='https://github.com/mattupstate/flask-security', license='MIT', author='Matthew Wright', From ed7beb529a6e85e62a25848f240cc2946924ab97 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 30 Apr 2012 19:34:04 -0400 Subject: [PATCH 002/234] First commit of refactored code and removed default models. Now meant to provide models from app level, not within Flask-Security --- example/app.py | 111 +++-- flask_security/__init__.py | 418 ++++++++---------- .../{datastore/__init__.py => datastore.py} | 252 +++++++---- flask_security/datastore/mongoengine.py | 71 --- flask_security/datastore/sqlalchemy.py | 92 ---- tests/functional_tests.py | 4 +- 6 files changed, 436 insertions(+), 512 deletions(-) rename flask_security/{datastore/__init__.py => datastore.py} (51%) delete mode 100644 flask_security/datastore/mongoengine.py delete mode 100644 flask_security/datastore/sqlalchemy.py diff --git a/example/app.py b/example/app.py index 3f86072e..e1223989 100644 --- a/example/app.py +++ b/example/app.py @@ -1,128 +1,159 @@ # a little trick so you can run: -# $ python example/app.py +# $ python example/app.py # from the root of the security project -import sys, os +import os +import sys sys.path.pop(0) sys.path.insert(0, os.getcwd()) -from flask import Flask, render_template +from flask import Flask, render_template, current_app from flask.ext.mongoengine import MongoEngine from flask.ext.sqlalchemy import SQLAlchemy -from flask.ext.security import (Security, LoginForm, user_datastore, - login_required, roles_required, roles_accepted) +from flask.ext.security import Security, LoginForm, login_required, \ + roles_required, roles_accepted, UserMixin, RoleMixin + +from flask.ext.security.datastore import SQLAlchemyUserDatastore, \ + MongoEngineUserDatastore -from flask.ext.security.datastore.sqlalchemy import SQLAlchemyUserDatastore -from flask.ext.security.datastore.mongoengine import MongoEngineUserDatastore def create_roles(): for role in ('admin', 'editor', 'author'): - user_datastore.create_role(name=role) - + current_app.security.datastore.create_role(name=role) + + def create_users(): - for u in (('matt','matt@lp.com','password',['admin'],True), - ('joe','joe@lp.com','password',['editor'],True), - ('jill','jill@lp.com','password',['author'],True), - ('tiya','tiya@lp.com','password',[],False)): - user_datastore.create_user(username=u[0], email=u[1], password=u[2], - roles=u[3], active=u[4]) + for u in (('matt', 'matt@lp.com', 'password', ['admin'], True), + ('joe', 'joe@lp.com', 'password', ['editor'], True), + ('jill', 'jill@lp.com', 'password', ['author'], True), + ('tiya', 'tiya@lp.com', 'password', [], False)): + current_app.security.datastore.create_user(username=u[0], email=u[1], + password=u[2], roles=u[3], active=u[4]) + def populate_data(): create_roles() create_users() - + + def create_app(auth_config): app = Flask(__name__) app.debug = True app.config['SECRET_KEY'] = 'secret' - + if auth_config: for key, value in auth_config.items(): app.config[key] = value - + @app.route('/') def index(): return render_template('index.html', content='Home Page') - + @app.route('/login') def login(): return render_template('login.html', content='Login Page', form=LoginForm()) - + @app.route('/custom_login') def custom_login(): return render_template('login.html', content='Custom Login Page', form=LoginForm()) - + @app.route('/profile') @login_required def profile(): return render_template('index.html', content='Profile Page') - + @app.route('/post_login') @login_required def post_login(): return render_template('index.html', content='Post Login') - + @app.route('/post_logout') def post_logout(): return render_template('index.html', content='Post Logout') - + @app.route('/admin') @roles_required('admin') def admin(): return render_template('index.html', content='Admin Page') - + @app.route('/admin_or_editor') @roles_accepted('admin', 'editor') def admin_or_editor(): return render_template('index.html', content='Admin or Editor Page') - + return app + def create_sqlalchemy_app(auth_config=None): app = create_app(auth_config) app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/flask_security_example.sqlite' - + db = SQLAlchemy(app) - class UserAccountMixin(): + roles_users = db.Table('roles_users', + db.Column('user_id', db.Integer(), db.ForeignKey('role.id')), + db.Column('role_id', db.Integer(), db.ForeignKey('user.id'))) + + class Role(db.Model, RoleMixin): + id = db.Column(db.Integer(), primary_key=True) + name = db.Column(db.String(80), unique=True) + description = db.Column(db.String(255)) + + class User(db.Model, UserMixin): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(255), unique=True) + email = db.Column(db.String(255), unique=True) + password = db.Column(db.String(120)) first_name = db.Column(db.String(120)) last_name = db.Column(db.String(120)) + active = db.Column(db.Boolean()) + created_at = db.Column(db.DateTime()) + modified_at = db.Column(db.DateTime()) + roles = db.relationship('Role', secondary=roles_users, + backref=db.backref('users', lazy='dynamic')) + + Security(app, SQLAlchemyUserDatastore(db, User, Role)) - Security(app, SQLAlchemyUserDatastore(db, UserAccountMixin)) - @app.before_first_request def before_first_request(): db.drop_all() db.create_all() populate_data() - + return app + def create_mongoengine_app(auth_config=None): app = create_app(auth_config) app.config['MONGODB_DB'] = 'flask_security_example' app.config['MONGODB_HOST'] = 'localhost' app.config['MONGODB_PORT'] = 27017 - + db = MongoEngine(app) - class UserAccountMixin(): - first_name = db.StringField(max_length=120) - last_name = db.StringField(max_length=120) + class Role(db.Document, RoleMixin): + name = db.StringField(required=True, unique=True, max_length=80) + description = db.StringField(max_length=255) + + class User(db.Document, UserMixin): + username = db.StringField(unique=True, max_length=255) + email = db.StringField(unique=True, max_length=255) + password = db.StringField(required=True, max_length=120) + active = db.BooleanField(default=True) + roles = db.ListField(db.ReferenceField(Role), default=[]) + + Security(app, MongoEngineUserDatastore(db, User, Role)) - Security(app, MongoEngineUserDatastore(db, UserAccountMixin)) - @app.before_first_request def before_first_request(): - from flask.ext.security import User, Role User.drop_collection() Role.drop_collection() populate_data() - + return app if __name__ == '__main__': app = create_sqlalchemy_app() #app = create_mongoengine_app() - app.run() \ No newline at end of file + app.run() diff --git a/flask_security/__init__.py b/flask_security/__init__.py index 4cb1a62a..a28d4efa 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -3,74 +3,42 @@ flask.ext.security ~~~~~~~~~~~~~~~~~~ - Flask-Security is a Flask extension that aims to add quick and simple + Flask-Security is a Flask extension that aims to add quick and simple security via Flask-Login, Flask-Principal, Flask-WTF, and passlib. :copyright: (c) 2012 by Matt Wright. :license: MIT, see LICENSE for more details. """ -import sys - -from datetime import datetime -from types import StringType - -from flask import (current_app, Blueprint, flash, redirect, request, - session, _request_ctx_stack, url_for, abort, g) - -from flask.ext.login import (AnonymousUser as AnonymousUserBase, - UserMixin as BaseUserMixin, LoginManager, login_required, login_user, - logout_user, current_user, user_logged_in, user_logged_out, - login_url) - -from flask.ext.principal import (Identity, Principal, RoleNeed, UserNeed, - Permission, AnonymousIdentity, identity_changed, identity_loaded) - -from flask.ext.wtf import (Form, TextField, PasswordField, SubmitField, - HiddenField, Required, ValidationError, BooleanField, Email) - from functools import wraps +from importlib import import_module + +from flask import current_app, Blueprint, flash, redirect, request, \ + session, url_for +from flask.ext.login import AnonymousUser as AnonymousUserBase, \ + UserMixin as BaseUserMixin, LoginManager, login_required, login_user, \ + logout_user, current_user, login_url +from flask.ext.principal import Identity, Principal, RoleNeed, UserNeed, \ + Permission, AnonymousIdentity, identity_changed, identity_loaded +from flask.ext.wtf import Form, TextField, PasswordField, SubmitField, \ + HiddenField, Required, BooleanField from passlib.context import CryptContext -from werkzeug.utils import import_string -from werkzeug.local import LocalProxy - -class User(object): - """User model""" - -class Role(object): - """Role model""" - -URL_PREFIX_KEY = 'SECURITY_URL_PREFIX' -AUTH_PROVIDER_KEY = 'SECURITY_AUTH_PROVIDER' -PASSWORD_HASH_KEY = 'SECURITY_PASSWORD_HASH' -USER_DATASTORE_KEY = 'SECURITY_USER_DATASTORE' -LOGIN_FORM_KEY = 'SECURITY_LOGIN_FORM' -AUTH_URL_KEY = 'SECURITY_AUTH_URL' -LOGOUT_URL_KEY = 'SECURITY_LOGOUT_URL' -LOGIN_VIEW_KEY = 'SECURITY_LOGIN_VIEW' -POST_LOGIN_KEY = 'SECURITY_POST_LOGIN' -POST_LOGOUT_KEY = 'SECURITY_POST_LOGOUT' -FLASH_MESSAGES_KEY = 'SECURITY_FLASH_MESSAGES' - -DEBUG_LOGIN = 'User %s logged in. Redirecting to: %s' -ERROR_LOGIN = 'Unsuccessful authentication attempt: %s. Redirecting to: %s' -DEBUG_LOGOUT = 'User logged out, redirecting to: %s' -FLASH_INACTIVE = 'Inactive user' -FLASH_PERMISSIONS = 'You do not have permission to view this resource.' +from werkzeug.datastructures import ImmutableList + #: Default Flask-Security configuration -default_config = { - URL_PREFIX_KEY: None, - FLASH_MESSAGES_KEY: True, - PASSWORD_HASH_KEY: 'plaintext', - USER_DATASTORE_KEY: 'user_datastore', - AUTH_PROVIDER_KEY: 'flask.ext.security.AuthenticationProvider', - LOGIN_FORM_KEY: 'flask.ext.security.LoginForm', - AUTH_URL_KEY: '/auth', - LOGOUT_URL_KEY: '/logout', - LOGIN_VIEW_KEY: '/login', - POST_LOGIN_KEY: '/', - POST_LOGOUT_KEY: '/', +_default_config = { + 'SECURITY_URL_PREFIX': None, + 'SECURITY_FLASH_MESSAGES': True, + 'SECURITY_PASSWORD_HASH': 'plaintext', + 'SECURITY_USER_DATASTORE': 'user_datastore', + 'SECURITY_AUTH_PROVIDER': 'flask.ext.security::AuthenticationProvider', + 'SECURITY_LOGIN_FORM': 'flask.ext.security::LoginForm', + 'SECURITY_AUTH_URL': '/auth', + 'SECURITY_LOGOUT_URL': '/logout', + 'SECURITY_LOGIN_VIEW': '/login', + 'SECURITY_POST_LOGIN_VIEW': '/', + 'SECURITY_POST_LOGOUT_VIEW': '/', } @@ -78,122 +46,114 @@ class BadCredentialsError(Exception): """Raised when an authentication attempt fails due to an error with the provided credentials. """ - + + class AuthenticationError(Exception): """Raised when an authentication attempt fails due to invalid configuration or an unknown reason. - """ - + """ + + class UserNotFoundError(Exception): - """Raised by a user datastore when there is an attempt to find a user by + """Raised by a user datastore when there is an attempt to find a user by their identifier, often username or email, and the user is not found. """ - + + class RoleNotFoundError(Exception): """Raised by a user datastore when there is an attempt to find a role and the role cannot be found. """ - + + class UserIdNotFoundError(Exception): - """Raised by a user datastore when there is an attempt to find a user by + """Raised by a user datastore when there is an attempt to find a user by ID and the user is not found. """ - + + class UserDatastoreError(Exception): """Raised when a user datastore experiences an unexpected error """ - + + class UserCreationError(Exception): """Raised when an error occurs when creating a user """ - + + class RoleCreationError(Exception): """Raised when an error occurs when creating a role """ - - -#: App logger for convenience -logger = LocalProxy(lambda: current_app.logger) - -#: Authentication provider -auth_provider = LocalProxy(lambda: current_app.auth_provider) - -#: Login manager -login_manager = LocalProxy(lambda: current_app.login_manager) -#: Password encyption context -pwd_context = LocalProxy(lambda: current_app.pwd_context) - -#: User datastore -user_datastore = LocalProxy(lambda: getattr(current_app, - current_app.config[USER_DATASTORE_KEY])) def roles_required(*args): """View decorator which specifies that a user must have all the specified roles. Example:: - + @app.route('/dashboard') @roles_required('admin', 'editor') def dashboard(): return 'Dashboard' - + The current user must have both the `admin` role and `editor` role in order to view the page. - - :param args: The required roles. + + :param args: The required roles. """ roles = args perm = Permission(*[RoleNeed(role) for role in roles]) + def wrapper(fn): @wraps(fn) def decorated_view(*args, **kwargs): if not current_user.is_authenticated(): - return redirect( - login_url(current_app.config[LOGIN_VIEW_KEY], request.url)) - + login_view = current_app.security.login_manager.login_view + return redirect(login_url(login_view, request.url)) + if perm.can(): return fn(*args, **kwargs) - - logger.debug('Identity does not provide all of the ' - 'following roles: %s' % [r for r in roles]) - - do_flash(FLASH_PERMISSIONS, 'error') + + current_app.logger.debug('Identity does not provide the ' + 'roles: %s' % [r for r in roles]) return redirect(request.referrer or '/') return decorated_view return wrapper def roles_accepted(*args): - """View decorator which specifies that a user must have at least one of the + """View decorator which specifies that a user must have at least one of the specified roles. Example:: - + @app.route('/create_post') @roles_accepted('editor', 'author') def create_post(): return 'Create Post' - - The current user must have either the `editor` role or `author` role in + + The current user must have either the `editor` role or `author` role in order to view the page. - - :param args: The possible roles. + + :param args: The possible roles. """ roles = args perms = [Permission(RoleNeed(role)) for role in roles] + def wrapper(fn): @wraps(fn) def decorated_view(*args, **kwargs): if not current_user.is_authenticated(): - return redirect( - login_url(current_app.config[LOGIN_VIEW_KEY], request.url)) - + login_view = current_app.security.login_manager.login_view + return redirect(login_url(login_view, request.url)) + for perm in perms: if perm.can(): return fn(*args, **kwargs) - - logger.debug('Identity does not provide at least one of ' - 'the following roles: %s' % [r for r in roles]) - - do_flash(FLASH_PERMISSIONS, 'error') + + current_app.logger.debug('Identity does not provide at least one ' + 'role: %s' % [r for r in roles]) + + _do_flash('You do not have permission to view this resource', + 'error') return redirect(request.referrer or '/') return decorated_view return wrapper @@ -202,30 +162,32 @@ def decorated_view(*args, **kwargs): class RoleMixin(object): """Mixin for `Role` model definitions""" def __eq__(self, other): + if isinstance(other, basestring): + return self.name == other return self.name == other.name - + def __ne__(self, other): + if isinstance(other, basestring): + return self.name != other return self.name != other.name - + def __str__(self): return '' % (self.name, self.description) class UserMixin(BaseUserMixin): """Mixin for `User` model definitions""" - + def is_active(self): - """Returns `True` if the user is active.""" + """Returns `True` if the user is active.""" return self.active - + def has_role(self, role): """Returns `True` if the user identifies with the specified role. - + :param role: A role name or `Role` instance""" - if not isinstance(role, Role): - role = Role(name=role) return role in self.roles - + def __str__(self): ctx = (str(self.id), self.username, self.email) return '' % ctx @@ -234,8 +196,8 @@ def __str__(self): class AnonymousUser(AnonymousUserBase): def __init__(self): super(AnonymousUser, self).__init__() - self.roles = [] # TODO: Make this immutable? - + self.roles = ImmutableList() + def has_role(self, *args): """Returns `False`""" return False @@ -243,145 +205,141 @@ def has_role(self, *args): class Security(object): """The :class:`Security` class initializes the Flask-Security extension. - + :param app: The application. :param datastore: An instance of a user datastore. """ def __init__(self, app=None, datastore=None): self.init_app(app, datastore) - + def init_app(self, app, datastore): - """Initializes the Flask-Security extension for the specified + """Initializes the Flask-Security extension for the specified application and datastore implentation. - + :param app: The application. :param datastore: An instance of a user datastore. """ - if app is None or datastore is None: return - - # TODO: change blueprint name - blueprint = Blueprint('auth', __name__) - - configured = {} - - for key, value in default_config.items(): - configured[key] = app.config.get(key, value) - - app.config.update(configured) - config = app.config - - # setup the login manager extension + if app is None or datastore is None: + return + + for key, value in _default_config.items(): + app.config.setdefault(key, value) + login_manager = LoginManager() login_manager.anonymous_user = AnonymousUser - login_manager.login_view = config[LOGIN_VIEW_KEY] + login_manager.login_view = _config_value(app, 'LOGIN_VIEW') login_manager.setup_app(app) - app.login_manager = login_manager - - Provider = get_class_from_config(AUTH_PROVIDER_KEY, config) - Form = get_class_from_config(LOGIN_FORM_KEY, config) - pw_hash = config[PASSWORD_HASH_KEY] - - app.pwd_context = CryptContext(schemes=[pw_hash], default=pw_hash) - app.auth_provider = Provider(Form) - app.principal = Principal(app) - - from flask.ext import security as s - s.User, s.Role = datastore.get_models() - - setattr(app, config[USER_DATASTORE_KEY], datastore) - + + Provider = _get_class_from_string(app, 'AUTH_PROVIDER') + Form = _get_class_from_string(app, 'LOGIN_FORM') + pw_hash = _config_value(app, 'PASSWORD_HASH') + + self.login_manager = login_manager + self.pwd_context = CryptContext(schemes=[pw_hash], default=pw_hash) + self.auth_provider = Provider(Form) + self.principal = Principal(app) + self.datastore = datastore + self.auth_url = _config_value(app, 'AUTH_URL') + self.logout_url = _config_value(app, 'LOGOUT_URL') + self.post_login_view = _config_value(app, 'POST_LOGIN_VIEW') + self.post_logout_view = _config_value(app, 'POST_LOGOUT_VIEW') + @identity_loaded.connect_via(app) def on_identity_loaded(sender, identity): if hasattr(current_user, 'id'): identity.provides.add(UserNeed(current_user.id)) - + for role in current_user.roles: identity.provides.add(RoleNeed(role.name)) - + identity.user = current_user - + @login_manager.user_loader def load_user(user_id): - try: - return datastore.with_id(user_id) + try: + return app.security.datastore.with_id(user_id) except Exception, e: - logger.error('Error getting user: %s' % e) + app.logger.error('Error getting user: %s' % e) return None - - auth_url = config[AUTH_URL_KEY] - @blueprint.route(auth_url, methods=['POST'], endpoint='authenticate') + + bp = Blueprint('auth', __name__) + + @bp.route(self.auth_url, methods=['POST'], endpoint='authenticate') def authenticate(): try: form = Form() - user = auth_provider.authenticate(form) - + user = current_app.security.auth_provider.authenticate(form) + if login_user(user, remember=form.remember.data): - redirect_url = get_post_login_redirect() + redirect_url = _get_post_login_redirect() identity_changed.send(app, identity=Identity(user.id)) - logger.debug(DEBUG_LOGIN % (user, redirect_url)) + app.logger.debug('User %s logged in. Redirecting to: ' + '%s' % (user, redirect_url)) return redirect(redirect_url) - raise BadCredentialsError(FLASH_INACTIVE) - + raise BadCredentialsError('Inactive user') + except BadCredentialsError, e: message = '%s' % e - do_flash(message, 'error') + _do_flash(message, 'error') redirect_url = request.referrer or login_manager.login_view - logger.error(ERROR_LOGIN % (message, redirect_url)) + app.logger.error('Unsuccessful authentication attempt: %s. ' + 'Redirect to: %s' % (message, redirect_url)) return redirect(redirect_url) - - @blueprint.route(config[LOGOUT_URL_KEY], endpoint='logout') + + @bp.route(self.logout_url, endpoint='logout') @login_required def logout(): for value in ('identity.name', 'identity.auth_type'): session.pop(value, None) - + identity_changed.send(app, identity=AnonymousIdentity()) logout_user() - - redirect_url = find_redirect(POST_LOGOUT_KEY) - logger.debug(DEBUG_LOGOUT % redirect_url) + + redirect_url = _find_redirect('SECURITY_POST_LOGOUT_VIEW') + app.logger.debug('User logged out. Redirect to: %s' % redirect_url) return redirect(redirect_url) - - app.register_blueprint(blueprint, url_prefix=config[URL_PREFIX_KEY]) - - + + app.register_blueprint(bp, url_prefix=_config_value(app, 'URL_PREFIX')) + app.security = self + + class LoginForm(Form): """The default login form""" - - username = TextField("Username or Email", + + username = TextField("Username or Email", validators=[Required(message="Username not provided")]) - password = PasswordField("Password", + password = PasswordField("Password", validators=[Required(message="Password not provided")]) remember = BooleanField("Remember Me") next = HiddenField() submit = SubmitField("Login") - + def __init__(self, *args, **kwargs): super(LoginForm, self).__init__(*args, **kwargs) self.next.data = request.args.get('next', None) - + class AuthenticationProvider(object): """The default authentication provider implementation. - + :param login_form_class: The login form class to use when authenticating a user """ - + def __init__(self, login_form_class=None): self.login_form_class = login_form_class or LoginForm - + def login_form(self, formdata=None): """Returns an instance of the login form with the provided form. - + :param formdata: The incoming form data""" return self.login_form_class(formdata) - + def authenticate(self, form): """Processes an authentication request and returns a user instance if authentication is successful. - + :param form: An instance of a populated login form """ if not form.validate(): @@ -389,19 +347,19 @@ def authenticate(self, form): raise BadCredentialsError(form.username.errors[0]) if form.password.errors: raise BadCredentialsError(form.password.errors[0]) - + return self.do_authenticate(form.username.data, form.password.data) - + def do_authenticate(self, user_identifier, password): """Returns the authenticated user if authentication is successfull. If authentication fails an appropriate error is raised - + :param user_identifier: The user's identifier, either an email address or username :param password: The user's unencrypted password """ try: - user = user_datastore.find_user(user_identifier) + user = current_app.security.datastore.find_user(user_identifier) except AttributeError, e: self.auth_error("Could not find user service: %s" % e) except UserNotFoundError, e: @@ -410,65 +368,61 @@ def do_authenticate(self, user_identifier, password): self.auth_error('Invalid user service: %s' % e) except Exception, e: self.auth_error('Unexpected authentication error: %s' % e) - + # compare passwords - if pwd_context.verify(password, user.password): + if current_app.security.pwd_context.verify(password, user.password): return user # bad match raise BadCredentialsError("Password does not match") - + def auth_error(self, msg): """Sends an error log message and raises an authentication error. - + :param msg: An authentication error message""" - logger.error(msg) + current_app.logger.error(msg) raise AuthenticationError(msg) -def do_flash(message, category): - if current_app.config[FLASH_MESSAGES_KEY]: - flash(message, category) +def _do_flash(message, category): + if _config_value(current_app, 'FLASH_MESSAGES'): + flash(message, category) -def get_class_by_name(clazz): - """Get a reference to a class by its string representation.""" - parts = clazz.split('.') - module = ".".join(parts[:-1]) - m = __import__( module ) - for comp in parts[1:]: - m = getattr(m, comp) - return m -def get_class_from_config(key, config): +def _get_class_from_string(app, key): """Get a reference to a class by its configuration key name.""" - try: - return get_class_by_name(config[key]) - except Exception, e: - raise AttributeError( - "Could not get class '%s' for Auth setting '%s' >> %s" % - (config[key], key, e)) + cv = _config_value(app, key).split('::') + cm = import_module(cv[0]) + return getattr(cm, cv[1]) + def get_url(endpoint_or_url): - """Returns a URL if a valid endpoint is found. Otherwise, returns the + """Returns a URL if a valid endpoint is found. Otherwise, returns the provided value.""" - try: + try: return url_for(endpoint_or_url) - except: + except: return endpoint_or_url -def get_post_login_redirect(): + +def _get_post_login_redirect(): """Returns the URL to redirect to after a user logs in successfully""" - return (get_url(request.args.get('next')) or - get_url(request.form.get('next')) or - find_redirect(POST_LOGIN_KEY)) + return (get_url(request.args.get('next')) or + get_url(request.form.get('next')) or + _find_redirect('SECURITY_POST_LOGIN_VIEW')) -def find_redirect(key): + +def _find_redirect(key): """Returns the URL to redirect to after a user logs in successfully""" - result = (get_url(session.pop(key.lower(), None)) or + result = (get_url(session.pop(key.lower(), None)) or get_url(current_app.config[key.upper()] or None) or '/') - - try: + + try: del session[key.lower()] - except: + except: pass return result + + +def _config_value(app, key): + return app.config['SECURITY_' + key.upper()] diff --git a/flask_security/datastore/__init__.py b/flask_security/datastore.py similarity index 51% rename from flask_security/datastore/__init__.py rename to flask_security/datastore.py index 92104485..abe86265 100644 --- a/flask_security/datastore/__init__.py +++ b/flask_security/datastore.py @@ -3,64 +3,62 @@ flask.ext.security.datastore ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - This module contains an abstracted user datastore. + This module contains an abstracted user datastore. :copyright: (c) 2012 by Matt Wright. :license: MIT, see LICENSE for more details. """ from datetime import datetime + +from flask import current_app + from flask.ext import security -from flask.ext.security import UserCreationError, RoleCreationError, pwd_context + class UserDatastore(object): - """Abstracted user datastore. Always extend this class and implement the - :attr:`get_models`, :attr:`_save_model`, :attr:`_do_with_id`, + """Abstracted user datastore. Always extend this class and implement the + :attr:`get_models`, :attr:`_save_model`, :attr:`_do_with_id`, :attr:`_do_find_user`, and :attr:`_do_find_role` methods. - - :param db: An instance of a configured databse manager from a Flask + + :param db: An instance of a configured databse manager from a Flask extension such as Flask-SQLAlchemy or Flask-MongoEngine - :param user_account_mixin: An optional mixin class that specifies additional - fields to be added to the user model + :param user_model: A user model class + :param role_model: A role model class """ - def __init__(self, db, user_account_mixin=None): + def __init__(self, db, user_model, role_model): self.db = db - self.user_account_mixin = user_account_mixin or object - - def get_models(self): - """Returns configured `User` and `Role` models for the datastore - implementation""" - raise NotImplementedError( - "User datastore does not implement get_models method") - + self.user_model = user_model + self.role_model = role_model + def _save_model(self, model, **kwargs): raise NotImplementedError( "User datastore does not implement _save_model method") - + def _do_with_id(self, id): raise NotImplementedError( "User datastore does not implement _do_with_id method") - + def _do_find_user(self): raise NotImplementedError( "User datastore does not implement _do_find_user method") - + def _do_find_role(self): raise NotImplementedError( "User datastore does not implement _do_find_role method") - + def _do_add_role(self, user, role): user, role = self._prepare_role_modify_args(user, role) if role not in user.roles: user.roles.append(role) return user - + def _do_remove_role(self, user, role): user, role = self._prepare_role_modify_args(user, role) if role in user.roles: user.roles.remove(role) return user - + def _do_toggle_active(self, user, active=None): user = self.find_user(user) if active is None: @@ -68,134 +66,238 @@ def _do_toggle_active(self, user, active=None): elif active != user.active: user.active = active return user - + def _do_deactive_user(self, user): return self._do_toggle_active(user, False) - + def _do_active_user(self, user): return self._do_toggle_active(user, True) - + def _prepare_role_modify_args(self, user, role): - if isinstance(user, security.User): + if isinstance(user, self.user_model): user = user.username or user.email - - if isinstance(role, security.Role): + + if isinstance(role, self.role_model): role = role.name - + return self.find_user(user), self.find_role(role) - + def _prepare_create_role_args(self, kwargs): for key in ('name', 'description'): kwargs[key] = kwargs.get(key, None) - + if kwargs['name'] is None: - raise RoleCreationError("Missing name argument") - + raise security.RoleCreationError("Missing name argument") + return kwargs - + def _prepare_create_user_args(self, kwargs): username = kwargs.get('username', None) email = kwargs.get('email', None) password = kwargs.get('password', None) - + if username is None and email is None: - raise UserCreationError('Missing username and/or email arguments') - + raise security.UserCreationError( + 'Missing username and/or email arguments') + if password is None: - raise UserCreationError('Missing password argument') - + raise security.UserCreationError('Missing password argument') + roles = kwargs.get('roles', []) - + for i, role in enumerate(roles): - rn = role.name if isinstance(role, security.Role) else role + rn = role.name if isinstance(role, self.role_model) else role # see if the role exists roles[i] = self.find_role(rn) - + kwargs['roles'] = roles - - now = datetime.utcnow() - kwargs['created_at'], kwargs['modified_at'] = now, now - + + pwd_context = current_app.security.pwd_context pw = kwargs['password'] if not pwd_context.identify(pw): kwargs['password'] = pwd_context.encrypt(pw) - + return kwargs - + def with_id(self, id): """Returns a user with the specified ID. - + :param id: User ID""" user = self._do_with_id(id) - if user: return user + if user: + return user raise security.UserIdNotFoundError() - + def find_user(self, user): - """Returns a user based on the specified identifier. - + """Returns a user based on the specified identifier. + :param user: User identifier, usually a username or email address """ user = self._do_find_user(user) - if user: return user + if user: + return user raise security.UserNotFoundError() - + def find_role(self, role): """Returns a role based on its name. - + :param role: Role name """ role = self._do_find_role(role) - if role: return role + if role: + return role raise security.RoleNotFoundError() - + def create_role(self, **kwargs): """Creates and returns a new role. - + :param name: Role name :param description: Role description """ - role = security.Role(**self._prepare_create_role_args(kwargs)) + role = self.role_model(**self._prepare_create_role_args(kwargs)) return self._save_model(role) - + def create_user(self, **kwargs): """Creates and returns a new user. - + :param username: Username :param email: Email address :param password: Unencrypted password :param active: The optional active state """ - user = security.User(**self._prepare_create_user_args(kwargs)) + user = self.user_model(**self._prepare_create_user_args(kwargs)) return self._save_model(user) - + def add_role_to_user(self, user, role): - """Adds a role to a user if the user does not have it already. Returns + """Adds a role to a user if the user does not have it already. Returns the modified user. - + :param user: A User instance or a user identifier :param role: A Role instance or a role name """ return self._save_model(self._do_add_role(user, role)) - + def remove_role_from_user(self, user, role, commit=True): - """Removes a role from a user if the user has the role. Returns the + """Removes a role from a user if the user has the role. Returns the modified user. - + :param user: A User instance or a user identifier :param role: A Role instance or a role name """ return self._save_model(self._do_remove_role(user, role)) - + def deactivate_user(self, user): """Deactivates a user and returns the modified user. - + :param user: A User instance or a user identifier """ return self._save_model(self._do_deactive_user(user)) - + def activate_user(self, user, commit=True): """Activates a user and returns the modified user. - + :param user: A User instance or a user identifier """ - return self._save_model(self._do_active_user(user)) \ No newline at end of file + return self._save_model(self._do_active_user(user)) + + +class SQLAlchemyUserDatastore(UserDatastore): + """A SQLAlchemy datastore implementation for Flask-Security. + Example usage:: + + from flask import Flask + from flask.ext.security import Security, SQLAlchemyUserDatastore + from flask.ext.sqlalchemy import SQLAlchemy + + app = Flask(__name__) + app.config['SECRET_KEY'] = 'secret' + app.config['SQLALCHEMY_DATABASE_URI'] = \ + 'sqlite:////tmp/flask_security_example.sqlite' + + db = SQLAlchemy(app) + + roles_users = db.Table('roles_users', + db.Column('user_id', db.Integer(), db.ForeignKey('role.id')), + db.Column('role_id', db.Integer(), db.ForeignKey('user.id'))) + + class Role(db.Model, RoleMixin): + id = db.Column(db.Integer(), primary_key=True) + name = db.Column(db.String(80), unique=True) + + class User(db.Model, UserMixin): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(255), unique=True) + email = db.Column(db.String(255), unique=True) + password = db.Column(db.String(120)) + first_name = db.Column(db.String(120)) + last_name = db.Column(db.String(120)) + active = db.Column(db.Boolean()) + created_at = db.Column(db.DateTime()) + modified_at = db.Column(db.DateTime()) + roles = db.relationship('Role', secondary=roles_users, + backref=db.backref('users', lazy='dynamic')) + + Security(app, SQLAlchemyUserDatastore(db, User, Role)) + """ + + def _save_model(self, model): + self.db.session.add(model) + self.db.session.commit() + return model + + def _do_with_id(self, id): + return self.user_model.query.get(id) + + def _do_find_user(self, user): + return self.user_model.query.filter_by(username=user).first() or \ + self.user_model.query.filter_by(email=user).first() + + def _do_find_role(self, role): + return self.role_model.query.filter_by(name=role).first() + + +class MongoEngineUserDatastore(UserDatastore): + """A MongoEngine datastore implementation for Flask-Security. + Example usage:: + + from flask import Flask + from flask.ext.mongoengine import MongoEngine + from flask.ext.security import Security, MongoEngineUserDatastore + + app = Flask(__name__) + app.config['SECRET_KEY'] = 'secret' + app.config['MONGODB_DB'] = 'flask_security_example' + app.config['MONGODB_HOST'] = 'localhost' + app.config['MONGODB_PORT'] = 27017 + + db = MongoEngine(app) + + class Role(db.Document, RoleMixin): + name = db.StringField(required=True, unique=True, max_length=80) + + class User(db.Document, UserMixin): + username = db.StringField(unique=True, max_length=255) + email = db.StringField(unique=True, max_length=255) + password = db.StringField(required=True, max_length=120) + active = db.BooleanField(default=True) + roles = db.ListField(db.ReferenceField(Role), default=[]) + + Security(app, MongoEngineUserDatastore(db, User, Role)) + """ + + def _save_model(self, model): + model.save() + return model + + def _do_with_id(self, id): + try: + return self.user_model.objects.get(id=id) + except: + return None + + def _do_find_user(self, user): + return self.user_model.objects(username=user).first() or \ + self.user_model.objects(email=user).first() + + def _do_find_role(self, role): + return self.role_model.objects(name=role).first() diff --git a/flask_security/datastore/mongoengine.py b/flask_security/datastore/mongoengine.py deleted file mode 100644 index 7e28c39a..00000000 --- a/flask_security/datastore/mongoengine.py +++ /dev/null @@ -1,71 +0,0 @@ -# -*- coding: utf-8 -*- -""" - flask.ext.security.datastore.mongoengine - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - This module contains a Flask-Security MongoEngine datastore implementation - - :copyright: (c) 2012 by Matt Wright. - :license: MIT, see LICENSE for more details. -""" - -from flask.ext import security -from flask.ext.security import UserMixin, RoleMixin -from flask.ext.security.datastore import UserDatastore - -class MongoEngineUserDatastore(UserDatastore): - """A MongoEngine datastore implementation for Flask-Security. - Example usage:: - - from flask import Flask - from flask.ext.mongoengine import MongoEngine - from flask.ext.security import Security - from flask.ext.security.datastore.mongoengine import MongoEngineUserDatastore - - app = Flask(__name__) - app.config['SECRET_KEY'] = 'secret' - app.config['MONGODB_DB'] = 'flask_security_example' - app.config['MONGODB_HOST'] = 'localhost' - app.config['MONGODB_PORT'] = 27017 - - db = MongoEngine(app) - Security(app, MongoEngineUserDatastore(db)) - """ - - def get_models(self): - db = self.db - - class Role(db.Document, RoleMixin): - """MongoEngine Role model""" - - name = db.StringField(required=True, unique=True, max_length=80) - description = db.StringField(max_length=255) - - class User(db.Document, UserMixin, self.user_account_mixin): - """MongoEngine User model""" - - username = db.StringField(unique=True, max_length=255) - email = db.StringField(unique=True, max_length=255) - password = db.StringField(required=True, max_length=120) - active = db.BooleanField(default=True) - roles= db.ListField(db.ReferenceField(Role), default=[]) - created_at = db.DateTimeField() - modified_at = db.DateTimeField() - - return User, Role - - def _save_model(self, model): - model.save() - return model - - def _do_with_id(self, id): - try: return security.User.objects.get(id=id) - except: return None - - def _do_find_user(self, user): - return security.User.objects(username=user).first() or \ - security.User.objects(email=user).first() - - def _do_find_role(self, role): - return security.Role.objects(name=role).first() - \ No newline at end of file diff --git a/flask_security/datastore/sqlalchemy.py b/flask_security/datastore/sqlalchemy.py deleted file mode 100644 index e239b51d..00000000 --- a/flask_security/datastore/sqlalchemy.py +++ /dev/null @@ -1,92 +0,0 @@ -# -*- coding: utf-8 -*- -""" - flask.ext.security.datastore.sqlalchemy - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - This module contains a Flask-Security SQLAlchemy datastore implementation - - :copyright: (c) 2012 by Matt Wright. - :license: MIT, see LICENSE for more details. -""" - -from flask.ext import security -from flask.ext.security import UserMixin, RoleMixin -from flask.ext.security.datastore import UserDatastore - -class SQLAlchemyUserDatastore(UserDatastore): - """A SQLAlchemy datastore implementation for Flask-Security. - Example usage:: - - from flask import Flask - from flask.ext.security import Security - from flask.ext.security.datastore.sqlalchemy import SQLAlchemyUserDatastore - from flask.ext.sqlalchemy import SQLAlchemy - - app = Flask(__name__) - app.config['SECRET_KEY'] = 'secret' - app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/flask_security_example.sqlite' - - db = SQLAlchemy(app) - Security(app, SQLAlchemyUserDatastore(db)) - """ - - def get_models(self): - db = self.db - - roles_users = db.Table('roles_users', - db.Column('user_id', db.Integer(), db.ForeignKey('role.id')), - db.Column('role_id', db.Integer(), db.ForeignKey('user.id'))) - - class Role(db.Model, RoleMixin): - """SQLAlchemy Role model""" - - id = db.Column(db.Integer(), primary_key=True) - name = db.Column(db.String(80), unique=True) - description = db.Column(db.String(255)) - - def __init__(self, name=None, description=None): - self.name = name - self.description = description - - class User(db.Model, UserMixin, self.user_account_mixin): - """SQLAlchemy User model""" - - id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(255), unique=True) - email = db.Column(db.String(255), unique=True) - password = db.Column(db.String(120)) - active = db.Column(db.Boolean()) - created_at = db.Column(db.DateTime()) - modified_at = db.Column(db.DateTime()) - - roles= db.relationship('Role', secondary=roles_users, - backref=db.backref('users', lazy='dynamic')) - - def __init__(self, username=None, email=None, password=None, - active=True, roles=None, - created_at=None, modified_at=None): - self.username = username - self.email = email - self.password = password - self.active = active - self.roles = roles or [] - self.created_at = created_at - self.modified_at = modified_at - - return User, Role - - def _save_model(self, model): - self.db.session.add(model) - self.db.session.commit() - return model - - def _do_with_id(self, id): - return security.User.query.get(id) - - def _do_find_user(self, user): - return security.User.query.filter_by(username=user).first() or \ - security.User.query.filter_by(email=user).first() - - def _do_find_role(self, role): - return security.Role.query.filter_by(name=role).first() - \ No newline at end of file diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 0451e7db..4ba161d8 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -114,8 +114,8 @@ class ConfiguredSecurityTests(SecurityTest): 'SECURITY_AUTH_URL': '/custom_auth', 'SECURITY_LOGOUT_URL': '/custom_logout', 'SECURITY_LOGIN_VIEW': '/custom_login', - 'SECURITY_POST_LOGIN': '/post_login', - 'SECURITY_POST_LOGOUT': '/post_logout' + 'SECURITY_POST_LOGIN_VIEW': '/post_login', + 'SECURITY_POST_LOGOUT_VIEW': '/post_logout' } def test_login_view(self): From 9b513ce4ab8352e364b99655958228e8e2c97c71 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 1 May 2012 11:19:46 -0400 Subject: [PATCH 003/234] Polish --- example/app.py | 3 --- flask_security/datastore.py | 5 +---- setup.py | 3 +-- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/example/app.py b/example/app.py index e1223989..c269b726 100644 --- a/example/app.py +++ b/example/app.py @@ -7,13 +7,10 @@ sys.path.insert(0, os.getcwd()) from flask import Flask, render_template, current_app - from flask.ext.mongoengine import MongoEngine from flask.ext.sqlalchemy import SQLAlchemy - from flask.ext.security import Security, LoginForm, login_required, \ roles_required, roles_accepted, UserMixin, RoleMixin - from flask.ext.security.datastore import SQLAlchemyUserDatastore, \ MongoEngineUserDatastore diff --git a/flask_security/datastore.py b/flask_security/datastore.py index abe86265..5275d979 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -3,16 +3,13 @@ flask.ext.security.datastore ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - This module contains an abstracted user datastore. + This module contains an user datastore classes. :copyright: (c) 2012 by Matt Wright. :license: MIT, see LICENSE for more details. """ -from datetime import datetime - from flask import current_app - from flask.ext import security diff --git a/setup.py b/setup.py index 3738b25e..b376be75 100644 --- a/setup.py +++ b/setup.py @@ -25,8 +25,7 @@ description='Simple security for Flask apps', long_description=__doc__, packages=[ - 'flask_security', - 'flask_security.datastore' + 'flask_security' ], zip_safe=False, include_package_data=True, From d285764592393dbc88b4c9cc349c4ca2afb4de26 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 1 May 2012 12:05:28 -0400 Subject: [PATCH 004/234] Make active be true by default --- flask_security/datastore.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 5275d979..4b1a17b3 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -92,6 +92,7 @@ def _prepare_create_user_args(self, kwargs): username = kwargs.get('username', None) email = kwargs.get('email', None) password = kwargs.get('password', None) + kwargs.setdefault('active', True) if username is None and email is None: raise security.UserCreationError( From 4625e9bdf376a94e7d295f760cce4f156515e84f Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 1 May 2012 15:43:14 -0400 Subject: [PATCH 005/234] Bump version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b376be75..93952f83 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ setup( name='Flask-Security', - version='1.2.3-dev', + version='1.3.0-dev', url='https://github.com/mattupstate/flask-security', license='MIT', author='Matthew Wright', From d6beaa43e35142a4ccf7cdd47fd06d29134c3d61 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 3 May 2012 18:02:02 -0400 Subject: [PATCH 006/234] Fix possible form object scope issue --- 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 a28d4efa..4cc1dec3 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -231,7 +231,6 @@ def init_app(self, app, datastore): login_manager.setup_app(app) Provider = _get_class_from_string(app, 'AUTH_PROVIDER') - Form = _get_class_from_string(app, 'LOGIN_FORM') pw_hash = _config_value(app, 'PASSWORD_HASH') self.login_manager = login_manager @@ -239,6 +238,7 @@ def init_app(self, app, datastore): self.auth_provider = Provider(Form) self.principal = Principal(app) self.datastore = datastore + self.form_class = _get_class_from_string(app, 'LOGIN_FORM') self.auth_url = _config_value(app, 'AUTH_URL') self.logout_url = _config_value(app, 'LOGOUT_URL') self.post_login_view = _config_value(app, 'POST_LOGIN_VIEW') @@ -267,7 +267,7 @@ def load_user(user_id): @bp.route(self.auth_url, methods=['POST'], endpoint='authenticate') def authenticate(): try: - form = Form() + form = current_app.security.form_class() user = current_app.security.auth_provider.authenticate(form) if login_user(user, remember=form.remember.data): From 5e8b53ef4629aa700e48ea5cc7a1a9190ea70f22 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 10 May 2012 12:31:37 -0400 Subject: [PATCH 007/234] Refactor views a bit to keep things cleaner and fix up tests --- flask_security/__init__.py | 117 ++++++++++++++++--------------------- flask_security/views.py | 54 +++++++++++++++++ tests/functional_tests.py | 30 +++++----- 3 files changed, 119 insertions(+), 82 deletions(-) create mode 100644 flask_security/views.py diff --git a/flask_security/__init__.py b/flask_security/__init__.py index 4cc1dec3..f42cc2c4 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -16,12 +16,13 @@ from flask import current_app, Blueprint, flash, redirect, request, \ session, url_for from flask.ext.login import AnonymousUser as AnonymousUserBase, \ - UserMixin as BaseUserMixin, LoginManager, login_required, login_user, \ - logout_user, current_user, login_url -from flask.ext.principal import Identity, Principal, RoleNeed, UserNeed, \ - Permission, AnonymousIdentity, identity_changed, identity_loaded + UserMixin as BaseUserMixin, LoginManager, login_required, \ + current_user, login_url +from flask.ext.principal import Principal, RoleNeed, UserNeed, \ + Permission, identity_loaded from flask.ext.wtf import Form, TextField, PasswordField, SubmitField, \ HiddenField, Required, BooleanField +from flask.ext.security import views from passlib.context import CryptContext from werkzeug.datastructures import ImmutableList @@ -36,9 +37,11 @@ 'SECURITY_LOGIN_FORM': 'flask.ext.security::LoginForm', 'SECURITY_AUTH_URL': '/auth', 'SECURITY_LOGOUT_URL': '/logout', + 'SECURITY_RESET_URL': '/reset', 'SECURITY_LOGIN_VIEW': '/login', 'SECURITY_POST_LOGIN_VIEW': '/', 'SECURITY_POST_LOGOUT_VIEW': '/', + 'SECURITY_RESET_PASSWORD_WITHIN': 10 } @@ -87,7 +90,7 @@ class RoleCreationError(Exception): """ -def roles_required(*args): +def roles_required(*roles): """View decorator which specifies that a user must have all the specified roles. Example:: @@ -101,7 +104,6 @@ def dashboard(): :param args: The required roles. """ - roles = args perm = Permission(*[RoleNeed(role) for role in roles]) def wrapper(fn): @@ -121,7 +123,7 @@ def decorated_view(*args, **kwargs): return wrapper -def roles_accepted(*args): +def roles_accepted(*roles): """View decorator which specifies that a user must have at least one of the specified roles. Example:: @@ -135,7 +137,6 @@ def create_post(): :param args: The possible roles. """ - roles = args perms = [Permission(RoleNeed(role)) for role in roles] def wrapper(fn): @@ -149,8 +150,9 @@ def decorated_view(*args, **kwargs): if perm.can(): return fn(*args, **kwargs) - current_app.logger.debug('Identity does not provide at least one ' - 'role: %s' % [r for r in roles]) + current_app.logger.debug('Current user does not provide a required ' + 'role. Accepted: %s Provided: %s' % ([r for r in roles], + [r.name for r in current_user.roles])) _do_flash('You do not have permission to view this resource', 'error') @@ -203,6 +205,24 @@ def has_role(self, *args): return False +def load_user(user_id): + try: + return current_app.security.datastore.with_id(user_id) + except Exception, e: + current_app.logger.error('Error getting user: %s' % e) + return None + + +def on_identity_loaded(sender, identity): + if hasattr(current_user, 'id'): + identity.provides.add(UserNeed(current_user.id)) + + for role in current_user.roles: + identity.provides.add(RoleNeed(role.name)) + + identity.user = current_user + + class Security(object): """The :class:`Security` class initializes the Flask-Security extension. @@ -212,7 +232,7 @@ class Security(object): def __init__(self, app=None, datastore=None): self.init_app(app, datastore) - def init_app(self, app, datastore): + def init_app(self, app, datastore, recoverable=False): """Initializes the Flask-Security extension for the specified application and datastore implentation. @@ -231,6 +251,7 @@ def init_app(self, app, datastore): login_manager.setup_app(app) Provider = _get_class_from_string(app, 'AUTH_PROVIDER') + Form = _get_class_from_string(app, 'LOGIN_FORM') pw_hash = _config_value(app, 'PASSWORD_HASH') self.login_manager = login_manager @@ -238,67 +259,31 @@ def init_app(self, app, datastore): self.auth_provider = Provider(Form) self.principal = Principal(app) self.datastore = datastore - self.form_class = _get_class_from_string(app, 'LOGIN_FORM') + self.form_class = Form self.auth_url = _config_value(app, 'AUTH_URL') self.logout_url = _config_value(app, 'LOGOUT_URL') + self.reset_url = _config_value(app, 'RESET_URL') self.post_login_view = _config_value(app, 'POST_LOGIN_VIEW') self.post_logout_view = _config_value(app, 'POST_LOGOUT_VIEW') + self.reset_password_within = _config_value(app, 'RESET_PASSWORD_WITHIN') - @identity_loaded.connect_via(app) - def on_identity_loaded(sender, identity): - if hasattr(current_user, 'id'): - identity.provides.add(UserNeed(current_user.id)) + identity_loaded.connect_via(app)(on_identity_loaded) - for role in current_user.roles: - identity.provides.add(RoleNeed(role.name)) + login_manager.user_loader(load_user) - identity.user = current_user + bp = Blueprint('auth', __name__) - @login_manager.user_loader - def load_user(user_id): - try: - return app.security.datastore.with_id(user_id) - except Exception, e: - app.logger.error('Error getting user: %s' % e) - return None + bp.route(self.auth_url, + methods=['POST'], + endpoint='authenticate')(views.authenticate) - bp = Blueprint('auth', __name__) + bp.route(self.logout_url, + endpoint='logout')(login_required(views.logout)) - @bp.route(self.auth_url, methods=['POST'], endpoint='authenticate') - def authenticate(): - try: - form = current_app.security.form_class() - user = current_app.security.auth_provider.authenticate(form) - - if login_user(user, remember=form.remember.data): - redirect_url = _get_post_login_redirect() - identity_changed.send(app, identity=Identity(user.id)) - app.logger.debug('User %s logged in. Redirecting to: ' - '%s' % (user, redirect_url)) - return redirect(redirect_url) - - raise BadCredentialsError('Inactive user') - - except BadCredentialsError, e: - message = '%s' % e - _do_flash(message, 'error') - redirect_url = request.referrer or login_manager.login_view - app.logger.error('Unsuccessful authentication attempt: %s. ' - 'Redirect to: %s' % (message, redirect_url)) - return redirect(redirect_url) - - @bp.route(self.logout_url, endpoint='logout') - @login_required - def logout(): - for value in ('identity.name', 'identity.auth_type'): - session.pop(value, None) - - identity_changed.send(app, identity=AnonymousIdentity()) - logout_user() - - redirect_url = _find_redirect('SECURITY_POST_LOGOUT_VIEW') - app.logger.debug('User logged out. Redirect to: %s' % redirect_url) - return redirect(redirect_url) + if recoverable: + bp.route(self.reset_url, + methods=['POST'], + endpoint='reset')(views.reset) app.register_blueprint(bp, url_prefix=_config_value(app, 'URL_PREFIX')) app.security = self @@ -361,11 +346,9 @@ def do_authenticate(self, user_identifier, password): try: user = current_app.security.datastore.find_user(user_identifier) except AttributeError, e: - self.auth_error("Could not find user service: %s" % e) + self.auth_error("Could not find user datastore: %s" % e) except UserNotFoundError, e: raise BadCredentialsError("Specified user does not exist") - except AttributeError, e: - self.auth_error('Invalid user service: %s' % e) except Exception, e: self.auth_error('Unexpected authentication error: %s' % e) @@ -424,5 +407,5 @@ def _find_redirect(key): return result -def _config_value(app, key): - return app.config['SECURITY_' + key.upper()] +def _config_value(app, key, default=None): + return app.config.get('SECURITY_' + key.upper(), default) diff --git a/flask_security/views.py b/flask_security/views.py new file mode 100644 index 00000000..74c7ffb2 --- /dev/null +++ b/flask_security/views.py @@ -0,0 +1,54 @@ + +from __future__ import absolute_import + +from flask import current_app, redirect, request, session +from flask.ext.login import login_user, logout_user +from flask.ext.principal import Identity, AnonymousIdentity, identity_changed +from flask.ext import security + + +def authenticate(): + try: + form = current_app.security.form_class() + user = current_app.security.auth_provider.authenticate(form) + + if login_user(user, remember=form.remember.data): + redirect_url = security._get_post_login_redirect() + identity_changed.send(current_app._get_current_object(), + identity=Identity(user.id)) + current_app.logger.debug('User %s logged in. Redirecting to: ' + '%s' % (user, redirect_url)) + return redirect(redirect_url) + + raise security.BadCredentialsError('Inactive user') + + except security.BadCredentialsError, e: + message = '%s' % e + security._do_flash(message, 'error') + redirect_url = request.referrer or \ + current_app.security.login_manager.login_view + current_app.logger.error('Unsuccessful authentication attempt: %s. ' + 'Redirect to: %s' % (message, redirect_url)) + return redirect(redirect_url) + + +def logout(): + for value in ('identity.name', 'identity.auth_type'): + session.pop(value, None) + + identity_changed.send(current_app._get_current_object(), + identity=AnonymousIdentity()) + logout_user() + + redirect_url = security._find_redirect('SECURITY_POST_LOGOUT_VIEW') + current_app.logger.debug('User logged out. Redirect to: %s' % redirect_url) + return redirect(redirect_url) + + +def reset(): + # user = something + # if reset_password_period_valid_for_user(user): + # user.reset_password_sent_at = datetime.utcnow() + # user.reset_password_token = token + # current_app.security.datastore._save_model(user) + pass diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 4ba161d8..290229d7 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -40,55 +40,55 @@ class DefaultSecurityTests(SecurityTest): def test_login_view(self): r = self._get('/login') - assert 'Login Page' in r.data + self.assertIn('Login Page', r.data) def test_authenticate(self): r = self.authenticate("matt", "password") - assert 'Home Page' in r.data + self.assertIn('Home Page', r.data) def test_unprovided_username(self): r = self.authenticate("", "password") - assert "Username not provided" in r.data + self.assertIn("Username not provided", r.data) def test_unprovided_password(self): r = self.authenticate("matt", "") - assert "Password not provided" in r.data + self.assertIn("Password not provided", r.data) def test_invalid_user(self): r = self.authenticate("bogus", "password") - assert "Specified user does not exist" in r.data + self.assertIn("Specified user does not exist", r.data) def test_bad_password(self): r = self.authenticate("matt", "bogus") - assert "Password does not match" in r.data + self.assertIn("Password does not match", r.data) def test_inactive_user(self): r = self.authenticate("tiya", "password") - assert "Inactive user" in r.data + self.assertIn("Inactive user", r.data) def test_logout(self): self.authenticate("matt", "password") r = self.logout() - assert 'Home Page' in r.data + self.assertIn('Home Page', r.data) def test_unauthorized_access(self): r = self._get('/profile', follow_redirects=True) - assert 'Please log in to access this page' in r.data + self.assertIn('Please log in to access this page', r.data) def test_authorized_access(self): self.authenticate("matt", "password") r = self._get("/profile") - assert 'profile' in r.data + self.assertIn('profile', r.data) def test_valid_admin_role(self): self.authenticate("matt", "password") r = self._get("/admin") - assert 'Admin Page' in r.data + self.assertIn('Admin Page', r.data) def test_invalid_admin_role(self): self.authenticate("joe", "password") r = self._get("/admin", follow_redirects=True) - assert 'Home Page' in r.data + self.assertIn('Home Page', r.data) def test_roles_accepted(self): for user in ("matt", "joe"): @@ -120,16 +120,16 @@ class ConfiguredSecurityTests(SecurityTest): def test_login_view(self): r = self._get('/custom_login') - assert "Custom Login Page" in r.data + self.assertIn("Custom Login Page", r.data) def test_authenticate(self): r = self.authenticate("matt", "password", endpoint="/custom_auth") - assert 'Post Login' in r.data + self.assertIn('Post Login', r.data) def test_logout(self): self.authenticate("matt", "password", endpoint="/custom_auth") r = self.logout(endpoint="/custom_logout") - assert 'Post Logout' in r.data + self.assertIn('Post Logout', r.data) class MongoEngineSecurityTests(DefaultSecurityTests): From 1ae711279edf85b89c00aed3fda1a91e7ad51f03 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 10 May 2012 12:45:43 -0400 Subject: [PATCH 008/234] Refactor modules --- flask_security/__init__.py | 140 +++++++---------------------------- flask_security/datastore.py | 16 ++-- flask_security/exceptions.py | 55 ++++++++++++++ flask_security/utils.py | 58 +++++++++++++++ flask_security/views.py | 22 ++++-- 5 files changed, 161 insertions(+), 130 deletions(-) create mode 100644 flask_security/exceptions.py create mode 100644 flask_security/utils.py diff --git a/flask_security/__init__.py b/flask_security/__init__.py index f42cc2c4..9c3b22d3 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -11,18 +11,16 @@ """ from functools import wraps -from importlib import import_module -from flask import current_app, Blueprint, flash, redirect, request, \ - session, url_for +from flask import current_app, Blueprint, redirect, request from flask.ext.login import AnonymousUser as AnonymousUserBase, \ - UserMixin as BaseUserMixin, LoginManager, login_required, \ - current_user, login_url + UserMixin as BaseUserMixin, LoginManager, login_required, \ + current_user, login_url from flask.ext.principal import Principal, RoleNeed, UserNeed, \ - Permission, identity_loaded + Permission, identity_loaded from flask.ext.wtf import Form, TextField, PasswordField, SubmitField, \ - HiddenField, Required, BooleanField -from flask.ext.security import views + HiddenField, Required, BooleanField +from flask.ext.security import views, exceptions, utils from passlib.context import CryptContext from werkzeug.datastructures import ImmutableList @@ -45,51 +43,6 @@ } -class BadCredentialsError(Exception): - """Raised when an authentication attempt fails due to an error with the - provided credentials. - """ - - -class AuthenticationError(Exception): - """Raised when an authentication attempt fails due to invalid configuration - or an unknown reason. - """ - - -class UserNotFoundError(Exception): - """Raised by a user datastore when there is an attempt to find a user by - their identifier, often username or email, and the user is not found. - """ - - -class RoleNotFoundError(Exception): - """Raised by a user datastore when there is an attempt to find a role and - the role cannot be found. - """ - - -class UserIdNotFoundError(Exception): - """Raised by a user datastore when there is an attempt to find a user by - ID and the user is not found. - """ - - -class UserDatastoreError(Exception): - """Raised when a user datastore experiences an unexpected error - """ - - -class UserCreationError(Exception): - """Raised when an error occurs when creating a user - """ - - -class RoleCreationError(Exception): - """Raised when an error occurs when creating a role - """ - - def roles_required(*roles): """View decorator which specifies that a user must have all the specified roles. Example:: @@ -154,7 +107,7 @@ def decorated_view(*args, **kwargs): 'role. Accepted: %s Provided: %s' % ([r for r in roles], [r.name for r in current_user.roles])) - _do_flash('You do not have permission to view this resource', + utils.do_flash('You do not have permission to view this resource', 'error') return redirect(request.referrer or '/') return decorated_view @@ -247,12 +200,12 @@ def init_app(self, app, datastore, recoverable=False): login_manager = LoginManager() login_manager.anonymous_user = AnonymousUser - login_manager.login_view = _config_value(app, 'LOGIN_VIEW') + login_manager.login_view = utils.config_value(app, 'LOGIN_VIEW') login_manager.setup_app(app) - Provider = _get_class_from_string(app, 'AUTH_PROVIDER') - Form = _get_class_from_string(app, 'LOGIN_FORM') - pw_hash = _config_value(app, 'PASSWORD_HASH') + Provider = utils.get_class_from_string(app, 'AUTH_PROVIDER') + Form = utils.get_class_from_string(app, 'LOGIN_FORM') + pw_hash = utils.config_value(app, 'PASSWORD_HASH') self.login_manager = login_manager self.pwd_context = CryptContext(schemes=[pw_hash], default=pw_hash) @@ -260,12 +213,12 @@ def init_app(self, app, datastore, recoverable=False): self.principal = Principal(app) self.datastore = datastore self.form_class = Form - self.auth_url = _config_value(app, 'AUTH_URL') - self.logout_url = _config_value(app, 'LOGOUT_URL') - self.reset_url = _config_value(app, 'RESET_URL') - self.post_login_view = _config_value(app, 'POST_LOGIN_VIEW') - self.post_logout_view = _config_value(app, 'POST_LOGOUT_VIEW') - self.reset_password_within = _config_value(app, 'RESET_PASSWORD_WITHIN') + self.auth_url = utils.config_value(app, 'AUTH_URL') + self.logout_url = utils.config_value(app, 'LOGOUT_URL') + self.reset_url = utils.config_value(app, 'RESET_URL') + self.post_login_view = utils.config_value(app, 'POST_LOGIN_VIEW') + self.post_logout_view = utils.config_value(app, 'POST_LOGOUT_VIEW') + self.reset_password_within = utils.config_value(app, 'RESET_PASSWORD_WITHIN') identity_loaded.connect_via(app)(on_identity_loaded) @@ -285,7 +238,8 @@ def init_app(self, app, datastore, recoverable=False): methods=['POST'], endpoint='reset')(views.reset) - app.register_blueprint(bp, url_prefix=_config_value(app, 'URL_PREFIX')) + app.register_blueprint(bp, + url_prefix=utils.config_value(app, 'URL_PREFIX')) app.security = self @@ -329,9 +283,9 @@ def authenticate(self, form): """ if not form.validate(): if form.username.errors: - raise BadCredentialsError(form.username.errors[0]) + raise exceptions.BadCredentialsError(form.username.errors[0]) if form.password.errors: - raise BadCredentialsError(form.password.errors[0]) + raise exceptions.BadCredentialsError(form.password.errors[0]) return self.do_authenticate(form.username.data, form.password.data) @@ -347,8 +301,8 @@ def do_authenticate(self, user_identifier, password): user = current_app.security.datastore.find_user(user_identifier) except AttributeError, e: self.auth_error("Could not find user datastore: %s" % e) - except UserNotFoundError, e: - raise BadCredentialsError("Specified user does not exist") + except exceptions.UserNotFoundError, e: + raise exceptions.BadCredentialsError("Specified user does not exist") except Exception, e: self.auth_error('Unexpected authentication error: %s' % e) @@ -357,55 +311,11 @@ def do_authenticate(self, user_identifier, password): return user # bad match - raise BadCredentialsError("Password does not match") + raise exceptions.BadCredentialsError("Password does not match") def auth_error(self, msg): """Sends an error log message and raises an authentication error. :param msg: An authentication error message""" current_app.logger.error(msg) - raise AuthenticationError(msg) - - -def _do_flash(message, category): - if _config_value(current_app, 'FLASH_MESSAGES'): - flash(message, category) - - -def _get_class_from_string(app, key): - """Get a reference to a class by its configuration key name.""" - cv = _config_value(app, key).split('::') - cm = import_module(cv[0]) - return getattr(cm, cv[1]) - - -def get_url(endpoint_or_url): - """Returns a URL if a valid endpoint is found. Otherwise, returns the - provided value.""" - try: - return url_for(endpoint_or_url) - except: - return endpoint_or_url - - -def _get_post_login_redirect(): - """Returns the URL to redirect to after a user logs in successfully""" - return (get_url(request.args.get('next')) or - get_url(request.form.get('next')) or - _find_redirect('SECURITY_POST_LOGIN_VIEW')) - - -def _find_redirect(key): - """Returns the URL to redirect to after a user logs in successfully""" - result = (get_url(session.pop(key.lower(), None)) or - get_url(current_app.config[key.upper()] or None) or '/') - - try: - del session[key.lower()] - except: - pass - return result - - -def _config_value(app, key, default=None): - return app.config.get('SECURITY_' + key.upper(), default) + raise exceptions.AuthenticationError(msg) diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 4b1a17b3..d80ac322 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ flask.ext.security.datastore - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This module contains an user datastore classes. @@ -10,7 +10,7 @@ """ from flask import current_app -from flask.ext import security +from flask.ext.security import exceptions class UserDatastore(object): @@ -84,7 +84,7 @@ def _prepare_create_role_args(self, kwargs): kwargs[key] = kwargs.get(key, None) if kwargs['name'] is None: - raise security.RoleCreationError("Missing name argument") + raise exceptions.RoleCreationError("Missing name argument") return kwargs @@ -95,11 +95,11 @@ def _prepare_create_user_args(self, kwargs): kwargs.setdefault('active', True) if username is None and email is None: - raise security.UserCreationError( + raise exceptions.UserCreationError( 'Missing username and/or email arguments') if password is None: - raise security.UserCreationError('Missing password argument') + raise exceptions.UserCreationError('Missing password argument') roles = kwargs.get('roles', []) @@ -124,7 +124,7 @@ def with_id(self, id): user = self._do_with_id(id) if user: return user - raise security.UserIdNotFoundError() + raise exceptions.UserIdNotFoundError() def find_user(self, user): """Returns a user based on the specified identifier. @@ -134,7 +134,7 @@ def find_user(self, user): user = self._do_find_user(user) if user: return user - raise security.UserNotFoundError() + raise exceptions.UserNotFoundError() def find_role(self, role): """Returns a role based on its name. @@ -144,7 +144,7 @@ def find_role(self, role): role = self._do_find_role(role) if role: return role - raise security.RoleNotFoundError() + raise exceptions.RoleNotFoundError() def create_role(self, **kwargs): """Creates and returns a new role. diff --git a/flask_security/exceptions.py b/flask_security/exceptions.py new file mode 100644 index 00000000..f15d4eaf --- /dev/null +++ b/flask_security/exceptions.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.exceptions + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security exceptions module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + + +class BadCredentialsError(Exception): + """Raised when an authentication attempt fails due to an error with the + provided credentials. + """ + + +class AuthenticationError(Exception): + """Raised when an authentication attempt fails due to invalid configuration + or an unknown reason. + """ + + +class UserNotFoundError(Exception): + """Raised by a user datastore when there is an attempt to find a user by + their identifier, often username or email, and the user is not found. + """ + + +class RoleNotFoundError(Exception): + """Raised by a user datastore when there is an attempt to find a role and + the role cannot be found. + """ + + +class UserIdNotFoundError(Exception): + """Raised by a user datastore when there is an attempt to find a user by + ID and the user is not found. + """ + + +class UserDatastoreError(Exception): + """Raised when a user datastore experiences an unexpected error + """ + + +class UserCreationError(Exception): + """Raised when an error occurs when creating a user + """ + + +class RoleCreationError(Exception): + """Raised when an error occurs when creating a role + """ diff --git a/flask_security/utils.py b/flask_security/utils.py new file mode 100644 index 00000000..dc754bb2 --- /dev/null +++ b/flask_security/utils.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.utils + ~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security utils module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + +from importlib import import_module + +from flask import url_for, flash, current_app, request, session + + +def do_flash(message, category): + if config_value(current_app, 'FLASH_MESSAGES'): + flash(message, category) + + +def get_class_from_string(app, key): + """Get a reference to a class by its configuration key name.""" + cv = config_value(app, key).split('::') + cm = import_module(cv[0]) + return getattr(cm, cv[1]) + + +def get_url(endpoint_or_url): + """Returns a URL if a valid endpoint is found. Otherwise, returns the + provided value.""" + try: + return url_for(endpoint_or_url) + except: + return endpoint_or_url + + +def get_post_login_redirect(): + """Returns the URL to redirect to after a user logs in successfully""" + return (get_url(request.args.get('next')) or + get_url(request.form.get('next')) or + find_redirect('SECURITY_POST_LOGIN_VIEW')) + + +def find_redirect(key): + """Returns the URL to redirect to after a user logs in successfully""" + result = (get_url(session.pop(key.lower(), None)) or + get_url(current_app.config[key.upper()] or None) or '/') + + try: + del session[key.lower()] + except: + pass + return result + + +def config_value(app, key, default=None): + return app.config.get('SECURITY_' + key.upper(), default) diff --git a/flask_security/views.py b/flask_security/views.py index 74c7ffb2..1c6632d4 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -1,10 +1,18 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.views + ~~~~~~~~~~~~~~~~~~~~~~~~ -from __future__ import absolute_import + Flask-Security views module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" from flask import current_app, redirect, request, session from flask.ext.login import login_user, logout_user from flask.ext.principal import Identity, AnonymousIdentity, identity_changed -from flask.ext import security +from flask.ext.security import exceptions, utils def authenticate(): @@ -13,18 +21,18 @@ def authenticate(): user = current_app.security.auth_provider.authenticate(form) if login_user(user, remember=form.remember.data): - redirect_url = security._get_post_login_redirect() + redirect_url = utils.get_post_login_redirect() identity_changed.send(current_app._get_current_object(), identity=Identity(user.id)) current_app.logger.debug('User %s logged in. Redirecting to: ' '%s' % (user, redirect_url)) return redirect(redirect_url) - raise security.BadCredentialsError('Inactive user') + raise exceptions.BadCredentialsError('Inactive user') - except security.BadCredentialsError, e: + except exceptions.BadCredentialsError, e: message = '%s' % e - security._do_flash(message, 'error') + utils.do_flash(message, 'error') redirect_url = request.referrer or \ current_app.security.login_manager.login_view current_app.logger.error('Unsuccessful authentication attempt: %s. ' @@ -40,7 +48,7 @@ def logout(): identity=AnonymousIdentity()) logout_user() - redirect_url = security._find_redirect('SECURITY_POST_LOGOUT_VIEW') + redirect_url = utils.find_redirect('SECURITY_POST_LOGOUT_VIEW') current_app.logger.debug('User logged out. Redirect to: %s' % redirect_url) return redirect(redirect_url) From 2b587f704781fb4652e727baa8d540c6649cd28c Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 11 May 2012 13:23:42 -0400 Subject: [PATCH 009/234] Starting a large refactor and adding confirmation abilities --- example/app.py | 31 +- example/templates/_nav.html | 2 +- example/templates/login.html | 4 +- example/templates/register.html | 10 + flask_security/__init__.py | 310 +-------------- flask_security/confirmable.py | 44 +++ flask_security/core.py | 368 ++++++++++++++++++ flask_security/datastore.py | 64 +-- flask_security/recoverable.py | 34 ++ flask_security/script.py | 102 ----- flask_security/templates/confirmed.html | 0 .../email/confirmation_instructions.html | 5 + .../email/confirmation_instructions.txt | 5 + flask_security/templates/login.html | 0 flask_security/utils.py | 7 + flask_security/views.py | 89 +++-- tests/functional_tests.py | 86 ++-- 17 files changed, 633 insertions(+), 528 deletions(-) create mode 100644 example/templates/register.html create mode 100644 flask_security/confirmable.py create mode 100644 flask_security/core.py create mode 100644 flask_security/recoverable.py delete mode 100644 flask_security/script.py create mode 100644 flask_security/templates/confirmed.html create mode 100644 flask_security/templates/email/confirmation_instructions.html create mode 100644 flask_security/templates/email/confirmation_instructions.txt create mode 100644 flask_security/templates/login.html diff --git a/example/app.py b/example/app.py index c269b726..a2c8ff85 100644 --- a/example/app.py +++ b/example/app.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + # a little trick so you can run: # $ python example/app.py # from the root of the security project @@ -7,6 +9,7 @@ sys.path.insert(0, os.getcwd()) from flask import Flask, render_template, current_app +from flask.ext.mail import Mail from flask.ext.mongoengine import MongoEngine from flask.ext.sqlalchemy import SQLAlchemy from flask.ext.security import Security, LoginForm, login_required, \ @@ -21,12 +24,12 @@ def create_roles(): def create_users(): - for u in (('matt', 'matt@lp.com', 'password', ['admin'], True), - ('joe', 'joe@lp.com', 'password', ['editor'], True), - ('jill', 'jill@lp.com', 'password', ['author'], True), - ('tiya', 'tiya@lp.com', 'password', [], False)): - current_app.security.datastore.create_user(username=u[0], email=u[1], - password=u[2], roles=u[3], active=u[4]) + for u in (('matt@lp.com', 'password', ['admin'], True), + ('joe@lp.com', 'password', ['editor'], True), + ('jill@lp.com', 'password', ['author'], True), + ('tiya@lp.com', 'password', [], False)): + current_app.security.datastore.create_user( + email=u[0], password=u[1], roles=u[2], active=u[3]) def populate_data(): @@ -43,6 +46,8 @@ def create_app(auth_config): for key, value in auth_config.items(): app.config[key] = value + app.mail = Mail(app) + @app.route('/') def index(): return render_template('index.html', content='Home Page') @@ -69,6 +74,10 @@ def post_login(): def post_logout(): return render_template('index.html', content='Post Logout') + @app.route('/post_register') + def post_register(): + return render_template('index.html', content='Post Register') + @app.route('/admin') @roles_required('admin') def admin(): @@ -99,14 +108,11 @@ class Role(db.Model, RoleMixin): class User(db.Model, UserMixin): id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(255), unique=True) email = db.Column(db.String(255), unique=True) password = db.Column(db.String(120)) - first_name = db.Column(db.String(120)) - last_name = db.Column(db.String(120)) active = db.Column(db.Boolean()) - created_at = db.Column(db.DateTime()) - modified_at = db.Column(db.DateTime()) + confirmation_token = db.Column(db.String(255)) + confirmation_sent_at = db.Column(db.DateTime()) roles = db.relationship('Role', secondary=roles_users, backref=db.backref('users', lazy='dynamic')) @@ -134,10 +140,11 @@ class Role(db.Document, RoleMixin): description = db.StringField(max_length=255) class User(db.Document, UserMixin): - username = db.StringField(unique=True, max_length=255) email = db.StringField(unique=True, max_length=255) password = db.StringField(required=True, max_length=120) active = db.BooleanField(default=True) + confirmation_token = db.StringField(max_length=255) + confirmation_sent_at = db.DateTimeField() roles = db.ListField(db.ReferenceField(Role), default=[]) Security(app, MongoEngineUserDatastore(db, User, Role)) diff --git a/example/templates/_nav.html b/example/templates/_nav.html index 9e07a4ef..3796955e 100644 --- a/example/templates/_nav.html +++ b/example/templates/_nav.html @@ -12,7 +12,7 @@ {% endif -%}
  • {%- if current_user.is_authenticated() -%} - Log out + Log out {%- else -%} Log in {%- endif -%} diff --git a/example/templates/login.html b/example/templates/login.html index d368e37d..137fc6cc 100644 --- a/example/templates/login.html +++ b/example/templates/login.html @@ -1,8 +1,8 @@ {% include "_messages.html" %} {% include "_nav.html" %} -
    + {{ form.hidden_tag() }} - {{ form.username.label }} {{ form.username }}
    + {{ form.email.label }} {{ form.email }}
    {{ form.password.label }} {{ form.password }}
    {{ form.remember.label }} {{ form.remember }}
    {{ form.next }} diff --git a/example/templates/register.html b/example/templates/register.html new file mode 100644 index 00000000..3bcde60c --- /dev/null +++ b/example/templates/register.html @@ -0,0 +1,10 @@ +{% include "_messages.html" %} +{% include "_nav.html" %} + + {{ form.hidden_tag() }} + {{ form.email.label }} {{ form.email }}
    + {{ form.password.label }} {{ form.password }}
    + {{ form.password_confirm.label }} {{ form.password_confirm }}
    + {{ form.submit }} +
    +

    {{ content }}

    diff --git a/flask_security/__init__.py b/flask_security/__init__.py index 9c3b22d3..74aa2140 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -10,312 +10,4 @@ :license: MIT, see LICENSE for more details. """ -from functools import wraps - -from flask import current_app, Blueprint, redirect, request -from flask.ext.login import AnonymousUser as AnonymousUserBase, \ - UserMixin as BaseUserMixin, LoginManager, login_required, \ - current_user, login_url -from flask.ext.principal import Principal, RoleNeed, UserNeed, \ - Permission, identity_loaded -from flask.ext.wtf import Form, TextField, PasswordField, SubmitField, \ - HiddenField, Required, BooleanField -from flask.ext.security import views, exceptions, utils -from passlib.context import CryptContext -from werkzeug.datastructures import ImmutableList - - -#: Default Flask-Security configuration -_default_config = { - 'SECURITY_URL_PREFIX': None, - 'SECURITY_FLASH_MESSAGES': True, - 'SECURITY_PASSWORD_HASH': 'plaintext', - 'SECURITY_USER_DATASTORE': 'user_datastore', - 'SECURITY_AUTH_PROVIDER': 'flask.ext.security::AuthenticationProvider', - 'SECURITY_LOGIN_FORM': 'flask.ext.security::LoginForm', - 'SECURITY_AUTH_URL': '/auth', - 'SECURITY_LOGOUT_URL': '/logout', - 'SECURITY_RESET_URL': '/reset', - 'SECURITY_LOGIN_VIEW': '/login', - 'SECURITY_POST_LOGIN_VIEW': '/', - 'SECURITY_POST_LOGOUT_VIEW': '/', - 'SECURITY_RESET_PASSWORD_WITHIN': 10 -} - - -def roles_required(*roles): - """View decorator which specifies that a user must have all the specified - roles. Example:: - - @app.route('/dashboard') - @roles_required('admin', 'editor') - def dashboard(): - return 'Dashboard' - - The current user must have both the `admin` role and `editor` role in order - to view the page. - - :param args: The required roles. - """ - perm = Permission(*[RoleNeed(role) for role in roles]) - - def wrapper(fn): - @wraps(fn) - def decorated_view(*args, **kwargs): - if not current_user.is_authenticated(): - login_view = current_app.security.login_manager.login_view - return redirect(login_url(login_view, request.url)) - - if perm.can(): - return fn(*args, **kwargs) - - current_app.logger.debug('Identity does not provide the ' - 'roles: %s' % [r for r in roles]) - return redirect(request.referrer or '/') - return decorated_view - return wrapper - - -def roles_accepted(*roles): - """View decorator which specifies that a user must have at least one of the - specified roles. Example:: - - @app.route('/create_post') - @roles_accepted('editor', 'author') - def create_post(): - return 'Create Post' - - The current user must have either the `editor` role or `author` role in - order to view the page. - - :param args: The possible roles. - """ - perms = [Permission(RoleNeed(role)) for role in roles] - - def wrapper(fn): - @wraps(fn) - def decorated_view(*args, **kwargs): - if not current_user.is_authenticated(): - login_view = current_app.security.login_manager.login_view - return redirect(login_url(login_view, request.url)) - - for perm in perms: - if perm.can(): - return fn(*args, **kwargs) - - current_app.logger.debug('Current user does not provide a required ' - 'role. Accepted: %s Provided: %s' % ([r for r in roles], - [r.name for r in current_user.roles])) - - utils.do_flash('You do not have permission to view this resource', - 'error') - return redirect(request.referrer or '/') - return decorated_view - return wrapper - - -class RoleMixin(object): - """Mixin for `Role` model definitions""" - def __eq__(self, other): - if isinstance(other, basestring): - return self.name == other - return self.name == other.name - - def __ne__(self, other): - if isinstance(other, basestring): - return self.name != other - return self.name != other.name - - def __str__(self): - return '' % (self.name, self.description) - - -class UserMixin(BaseUserMixin): - """Mixin for `User` model definitions""" - - def is_active(self): - """Returns `True` if the user is active.""" - return self.active - - def has_role(self, role): - """Returns `True` if the user identifies with the specified role. - - :param role: A role name or `Role` instance""" - return role in self.roles - - def __str__(self): - ctx = (str(self.id), self.username, self.email) - return '' % ctx - - -class AnonymousUser(AnonymousUserBase): - def __init__(self): - super(AnonymousUser, self).__init__() - self.roles = ImmutableList() - - def has_role(self, *args): - """Returns `False`""" - return False - - -def load_user(user_id): - try: - return current_app.security.datastore.with_id(user_id) - except Exception, e: - current_app.logger.error('Error getting user: %s' % e) - return None - - -def on_identity_loaded(sender, identity): - if hasattr(current_user, 'id'): - identity.provides.add(UserNeed(current_user.id)) - - for role in current_user.roles: - identity.provides.add(RoleNeed(role.name)) - - identity.user = current_user - - -class Security(object): - """The :class:`Security` class initializes the Flask-Security extension. - - :param app: The application. - :param datastore: An instance of a user datastore. - """ - def __init__(self, app=None, datastore=None): - self.init_app(app, datastore) - - def init_app(self, app, datastore, recoverable=False): - """Initializes the Flask-Security extension for the specified - application and datastore implentation. - - :param app: The application. - :param datastore: An instance of a user datastore. - """ - if app is None or datastore is None: - return - - for key, value in _default_config.items(): - app.config.setdefault(key, value) - - login_manager = LoginManager() - login_manager.anonymous_user = AnonymousUser - login_manager.login_view = utils.config_value(app, 'LOGIN_VIEW') - login_manager.setup_app(app) - - Provider = utils.get_class_from_string(app, 'AUTH_PROVIDER') - Form = utils.get_class_from_string(app, 'LOGIN_FORM') - pw_hash = utils.config_value(app, 'PASSWORD_HASH') - - self.login_manager = login_manager - self.pwd_context = CryptContext(schemes=[pw_hash], default=pw_hash) - self.auth_provider = Provider(Form) - self.principal = Principal(app) - self.datastore = datastore - self.form_class = Form - self.auth_url = utils.config_value(app, 'AUTH_URL') - self.logout_url = utils.config_value(app, 'LOGOUT_URL') - self.reset_url = utils.config_value(app, 'RESET_URL') - self.post_login_view = utils.config_value(app, 'POST_LOGIN_VIEW') - self.post_logout_view = utils.config_value(app, 'POST_LOGOUT_VIEW') - self.reset_password_within = utils.config_value(app, 'RESET_PASSWORD_WITHIN') - - identity_loaded.connect_via(app)(on_identity_loaded) - - login_manager.user_loader(load_user) - - bp = Blueprint('auth', __name__) - - bp.route(self.auth_url, - methods=['POST'], - endpoint='authenticate')(views.authenticate) - - bp.route(self.logout_url, - endpoint='logout')(login_required(views.logout)) - - if recoverable: - bp.route(self.reset_url, - methods=['POST'], - endpoint='reset')(views.reset) - - app.register_blueprint(bp, - url_prefix=utils.config_value(app, 'URL_PREFIX')) - app.security = self - - -class LoginForm(Form): - """The default login form""" - - username = TextField("Username or Email", - validators=[Required(message="Username not provided")]) - password = PasswordField("Password", - validators=[Required(message="Password not provided")]) - remember = BooleanField("Remember Me") - next = HiddenField() - submit = SubmitField("Login") - - def __init__(self, *args, **kwargs): - super(LoginForm, self).__init__(*args, **kwargs) - self.next.data = request.args.get('next', None) - - -class AuthenticationProvider(object): - """The default authentication provider implementation. - - :param login_form_class: The login form class to use when authenticating a - user - """ - - def __init__(self, login_form_class=None): - self.login_form_class = login_form_class or LoginForm - - def login_form(self, formdata=None): - """Returns an instance of the login form with the provided form. - - :param formdata: The incoming form data""" - return self.login_form_class(formdata) - - def authenticate(self, form): - """Processes an authentication request and returns a user instance if - authentication is successful. - - :param form: An instance of a populated login form - """ - if not form.validate(): - if form.username.errors: - raise exceptions.BadCredentialsError(form.username.errors[0]) - if form.password.errors: - raise exceptions.BadCredentialsError(form.password.errors[0]) - - return self.do_authenticate(form.username.data, form.password.data) - - def do_authenticate(self, user_identifier, password): - """Returns the authenticated user if authentication is successfull. If - authentication fails an appropriate error is raised - - :param user_identifier: The user's identifier, either an email address - or username - :param password: The user's unencrypted password - """ - try: - user = current_app.security.datastore.find_user(user_identifier) - except AttributeError, e: - self.auth_error("Could not find user datastore: %s" % e) - except exceptions.UserNotFoundError, e: - raise exceptions.BadCredentialsError("Specified user does not exist") - except Exception, e: - self.auth_error('Unexpected authentication error: %s' % e) - - # compare passwords - if current_app.security.pwd_context.verify(password, user.password): - return user - - # bad match - raise exceptions.BadCredentialsError("Password does not match") - - def auth_error(self, msg): - """Sends an error log message and raises an authentication error. - - :param msg: An authentication error message""" - current_app.logger.error(msg) - raise exceptions.AuthenticationError(msg) +from .core import * diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py new file mode 100644 index 00000000..5a9cf85c --- /dev/null +++ b/flask_security/confirmable.py @@ -0,0 +1,44 @@ + +from datetime import datetime + +from flask import render_template, current_app, request, url_for +from flask.ext.security.utils import generate_token +from werkzeug.local import LocalProxy + + +logger = LocalProxy(lambda: current_app.logger) + + +def send_confirmation_instructions(user): + from flask.ext.mail import Message + + msg = Message("Please confirm your email", + sender=current_app.security.email_sender, + recipients=[user.email]) + + confirmation_link = request.url_root[:-1] + \ + url_for('flask_security.confirm', + confirmation_token=user.confirmation_token) + + ctx = dict(user=user, confirmation_link=confirmation_link) + msg.body = render_template('email/confirmation_instructions.txt', **ctx) + msg.html = render_template('email/confirmation_instructions.html', **ctx) + + logger.debug("Sending confirmation instructions") + logger.debug(msg.html) + + current_app.mail.send(msg) + + +def generate_confirmation_token(user): + token = generate_token() + now = datetime.utcnow() + + if isinstance(user, dict): + user['confirmation_token'] = token + user['confirmation_sent_at'] = now + else: + user.confirmation_token = token + user.confirmation_sent_at = now + + return user diff --git a/flask_security/core.py b/flask_security/core.py new file mode 100644 index 00000000..95d55e54 --- /dev/null +++ b/flask_security/core.py @@ -0,0 +1,368 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.core + ~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security core module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + +from datetime import timedelta +from functools import wraps + +from flask import current_app, Blueprint, redirect, request +from flask.ext.login import AnonymousUser as AnonymousUserBase, \ + UserMixin as BaseUserMixin, LoginManager, login_required, \ + current_user, login_url +from flask.ext.principal import Principal, RoleNeed, UserNeed, \ + Permission, identity_loaded +from flask.ext.wtf import Form, TextField, PasswordField, SubmitField, \ + HiddenField, Required, BooleanField, EqualTo, Email +from flask.ext.security import views, exceptions, utils +from passlib.context import CryptContext +from werkzeug.datastructures import ImmutableList + + +#: Default Flask-Security configuration +_default_config = { + 'SECURITY_URL_PREFIX': None, + 'SECURITY_FLASH_MESSAGES': True, + 'SECURITY_PASSWORD_HASH': 'plaintext', + 'SECURITY_AUTH_PROVIDER': 'flask.ext.security::AuthenticationProvider', + 'SECURITY_LOGIN_FORM': 'flask.ext.security::LoginForm', + 'SECURITY_REGISTER_FORM': 'flask.ext.security::RegisterForm', + 'SECURITY_AUTH_URL': '/auth', + 'SECURITY_LOGOUT_URL': '/logout', + 'SECURITY_REGISTER_URL': '/register', + 'SECURITY_RESET_URL': '/reset', + 'SECURITY_CONFIRM_URL': '/confirm', + 'SECURITY_LOGIN_VIEW': '/login', + 'SECURITY_POST_LOGIN_VIEW': '/', + 'SECURITY_POST_LOGOUT_VIEW': '/', + 'SECURITY_POST_REGISTER_VIEW': '/', + 'SECURITY_RESET_PASSWORD_WITHIN': 10, + 'SECURITY_DEFAULT_ROLES': [], + 'SECURITY_LOGIN_WITHOUT_CONFIRMATION': True, + 'SECURITY_CONFIRM_EMAIL': False, + 'SECURITY_CONFIRM_EMAIL_WITHIN': '5 days', + 'SECURITY_EMAIL_SENDER': 'no-reply@localhost' +} + + +def roles_required(*roles): + """View decorator which specifies that a user must have all the specified + roles. Example:: + + @app.route('/dashboard') + @roles_required('admin', 'editor') + def dashboard(): + return 'Dashboard' + + The current user must have both the `admin` role and `editor` role in order + to view the page. + + :param args: The required roles. + """ + perm = Permission(*[RoleNeed(role) for role in roles]) + + def wrapper(fn): + @wraps(fn) + def decorated_view(*args, **kwargs): + if not current_user.is_authenticated(): + login_view = current_app.security.login_manager.login_view + return redirect(login_url(login_view, request.url)) + + if perm.can(): + return fn(*args, **kwargs) + + current_app.logger.debug('Identity does not provide the ' + 'roles: %s' % [r for r in roles]) + return redirect(request.referrer or '/') + return decorated_view + return wrapper + + +def roles_accepted(*roles): + """View decorator which specifies that a user must have at least one of the + specified roles. Example:: + + @app.route('/create_post') + @roles_accepted('editor', 'author') + def create_post(): + return 'Create Post' + + The current user must have either the `editor` role or `author` role in + order to view the page. + + :param args: The possible roles. + """ + perms = [Permission(RoleNeed(role)) for role in roles] + + def wrapper(fn): + @wraps(fn) + def decorated_view(*args, **kwargs): + if not current_user.is_authenticated(): + login_view = current_app.security.login_manager.login_view + return redirect(login_url(login_view, request.url)) + + for perm in perms: + if perm.can(): + return fn(*args, **kwargs) + + r1 = [r for r in roles] + r2 = [r.name for r in current_user.roles] + + current_app.logger.debug('Current user does not provide a ' + 'required role. Accepted: %s Provided: %s' % (r1, r2)) + + utils.do_flash('You do not have permission to ' + 'view this resource', 'error') + + return redirect(request.referrer or '/') + return decorated_view + return wrapper + + +class RoleMixin(object): + """Mixin for `Role` model definitions""" + def __eq__(self, other): + if isinstance(other, basestring): + return self.name == other + return self.name == other.name + + def __ne__(self, other): + if isinstance(other, basestring): + return self.name != other + return self.name != other.name + + def __str__(self): + return '' % self.name + + +class UserMixin(BaseUserMixin): + """Mixin for `User` model definitions""" + + def is_active(self): + """Returns `True` if the user is active.""" + return self.active + + def has_role(self, role): + """Returns `True` if the user identifies with the specified role. + + :param role: A role name or `Role` instance""" + return role in self.roles + + def __str__(self): + ctx = (str(self.id), self.email) + return '' % ctx + + +class AnonymousUser(AnonymousUserBase): + def __init__(self): + super(AnonymousUser, self).__init__() + self.roles = ImmutableList() + + def has_role(self, *args): + """Returns `False`""" + return False + + +def load_user(user_id): + try: + return current_app.security.datastore.with_id(user_id) + except Exception, e: + current_app.logger.error('Error getting user: %s' % e) + return None + + +def on_identity_loaded(sender, identity): + if hasattr(current_user, 'id'): + identity.provides.add(UserNeed(current_user.id)) + + for role in current_user.roles: + identity.provides.add(RoleNeed(role.name)) + + identity.user = current_user + + +class Security(object): + """The :class:`Security` class initializes the Flask-Security extension. + + :param app: The application. + :param datastore: An instance of a user datastore. + """ + def __init__(self, app=None, datastore=None, **kwargs): + self.init_app(app, datastore, **kwargs) + + def init_app(self, app, datastore, + registerable=True, recoverable=False, template_folder=None): + """Initializes the Flask-Security extension for the specified + application and datastore implentation. + + :param app: The application. + :param datastore: An instance of a user datastore. + """ + if app is None or datastore is None: + return + + for key, value in _default_config.items(): + app.config.setdefault(key, value) + + login_manager = LoginManager() + login_manager.anonymous_user = AnonymousUser + login_manager.login_view = utils.config_value(app, 'LOGIN_VIEW') + login_manager.user_loader(load_user) + login_manager.setup_app(app) + + Provider = utils.get_class_from_string(app, 'AUTH_PROVIDER') + pw_hash = utils.config_value(app, 'PASSWORD_HASH') + + self.login_manager = login_manager + self.pwd_context = CryptContext(schemes=[pw_hash], default=pw_hash) + self.auth_provider = Provider(Form) + self.principal = Principal(app) + self.datastore = datastore + self.LoginForm = utils.get_class_from_string(app, 'LOGIN_FORM') + self.RegisterForm = utils.get_class_from_string(app, 'REGISTER_FORM') + self.auth_url = utils.config_value(app, 'AUTH_URL') + self.logout_url = utils.config_value(app, 'LOGOUT_URL') + self.reset_url = utils.config_value(app, 'RESET_URL') + self.register_url = utils.config_value(app, 'REGISTER_URL') + self.confirm_url = utils.config_value(app, 'CONFIRM_URL') + self.post_login_view = utils.config_value(app, 'POST_LOGIN_VIEW') + self.post_logout_view = utils.config_value(app, 'POST_LOGOUT_VIEW') + self.post_register_view = utils.config_value(app, 'POST_REGISTER_VIEW') + self.reset_password_within = utils.config_value(app, 'RESET_PASSWORD_WITHIN') + self.default_roles = utils.config_value(app, "DEFAULT_ROLES") + self.login_without_confirmation = utils.config_value(app, 'LOGIN_WITHOUT_CONFIRMATION') + self.confirm_email = utils.config_value(app, 'CONFIRM_EMAIL') + self.email_sender = utils.config_value(app, 'EMAIL_SENDER') + + values = utils.config_value(app, 'CONFIRM_EMAIL_WITHIN').split() + self.confirm_email_within = timedelta(**{values[1]: int(values[0])}) + + identity_loaded.connect_via(app)(on_identity_loaded) + + bp = Blueprint('flask_security', __name__, template_folder='templates') + + bp.route(self.auth_url, + methods=['POST'], + endpoint='authenticate')(views.authenticate) + + bp.route(self.logout_url, + endpoint='logout')(login_required(views.logout)) + + self.setup_register(bp) if registerable else None + self.setup_reset(bp) if recoverable else None + self.setup_confirm(bp) if self.confirm_email else None + + app.register_blueprint(bp, + url_prefix=utils.config_value(app, 'URL_PREFIX')) + + app.security = self + + def setup_register(self, bp): + bp.route(self.register_url, + methods=['POST'], + endpoint='register')(views.register) + + def setup_reset(self, bp): + bp.route(self.reset_url, + methods=['POST'], + endpoint='reset')(views.reset) + + def setup_confirm(self, bp): + bp.route(self.confirm_url, endpoint='confirm')(views.confirm) + + +class LoginForm(Form): + """The default login form""" + + email = TextField("Email Address", + validators=[Required(message="Email not provided")]) + password = PasswordField("Password", + validators=[Required(message="Password not provided")]) + remember = BooleanField("Remember Me") + next = HiddenField() + submit = SubmitField("Login") + + def __init__(self, *args, **kwargs): + super(LoginForm, self).__init__(*args, **kwargs) + self.next.data = request.args.get('next', None) + + +class RegisterForm(Form): + """The default register form""" + + email = TextField("Email Address", + validators=[Required(message='Email not provided'), Email()]) + password = PasswordField("Password", + validators=[Required(message="Password not provided")]) + password_confirm = PasswordField("Password", + validators=[EqualTo('password', message="Password not provided")]) + + def to_dict(self): + return dict(email=self.email.data, password=self.password.data) + + +class AuthenticationProvider(object): + """The default authentication provider implementation. + + :param login_form_class: The login form class to use when authenticating a + user + """ + + def __init__(self, login_form_class=None): + self.login_form_class = login_form_class or LoginForm + + def login_form(self, formdata=None): + """Returns an instance of the login form with the provided form. + + :param formdata: The incoming form data""" + return self.login_form_class(formdata) + + def authenticate(self, form): + """Processes an authentication request and returns a user instance if + authentication is successful. + + :param form: An instance of a populated login form + """ + if not form.validate(): + if form.email.errors: + raise exceptions.BadCredentialsError(form.email.errors[0]) + if form.password.errors: + raise exceptions.BadCredentialsError(form.password.errors[0]) + + return self.do_authenticate(form.email.data, form.password.data) + + def do_authenticate(self, user_identifier, password): + """Returns the authenticated user if authentication is successfull. If + authentication fails an appropriate error is raised + + :param user_identifier: The user's identifier, usuall an email address + :param password: The user's unencrypted password + """ + try: + user = current_app.security.datastore.find_user(user_identifier) + except AttributeError, e: + self.auth_error("Could not find user datastore: %s" % e) + except exceptions.UserNotFoundError, e: + raise exceptions.BadCredentialsError("Specified user does not exist") + except Exception, e: + self.auth_error('Unexpected authentication error: %s' % e) + + # compare passwords + if current_app.security.pwd_context.verify(password, user.password): + return user + + # bad match + raise exceptions.BadCredentialsError("Password does not match") + + def auth_error(self, msg): + """Sends an error log message and raises an authentication error. + + :param msg: An authentication error message""" + current_app.logger.error(msg) + raise exceptions.AuthenticationError(msg) diff --git a/flask_security/datastore.py b/flask_security/datastore.py index d80ac322..87b65871 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -15,13 +15,13 @@ class UserDatastore(object): """Abstracted user datastore. Always extend this class and implement the - :attr:`get_models`, :attr:`_save_model`, :attr:`_do_with_id`, - :attr:`_do_find_user`, and :attr:`_do_find_role` methods. + :attr:`_save_model`, :attr:`_do_with_id`, :attr:`_do_find_user`, and + :attr:`_do_find_role` methods. :param db: An instance of a configured databse manager from a Flask extension such as Flask-SQLAlchemy or Flask-MongoEngine - :param user_model: A user model class - :param role_model: A role model class + :param user_model: A user model class definition + :param role_model: A role model class definition """ def __init__(self, db, user_model, role_model): self.db = db @@ -44,59 +44,19 @@ def _do_find_role(self): raise NotImplementedError( "User datastore does not implement _do_find_role method") - def _do_add_role(self, user, role): - user, role = self._prepare_role_modify_args(user, role) - if role not in user.roles: - user.roles.append(role) - return user - - def _do_remove_role(self, user, role): - user, role = self._prepare_role_modify_args(user, role) - if role in user.roles: - user.roles.remove(role) - return user - - def _do_toggle_active(self, user, active=None): - user = self.find_user(user) - if active is None: - user.active = not user.active - elif active != user.active: - user.active = active - return user - - def _do_deactive_user(self, user): - return self._do_toggle_active(user, False) - - def _do_active_user(self, user): - return self._do_toggle_active(user, True) - - def _prepare_role_modify_args(self, user, role): - if isinstance(user, self.user_model): - user = user.username or user.email - - if isinstance(role, self.role_model): - role = role.name - - return self.find_user(user), self.find_role(role) - def _prepare_create_role_args(self, kwargs): - for key in ('name', 'description'): - kwargs[key] = kwargs.get(key, None) - if kwargs['name'] is None: raise exceptions.RoleCreationError("Missing name argument") return kwargs def _prepare_create_user_args(self, kwargs): - username = kwargs.get('username', None) email = kwargs.get('email', None) password = kwargs.get('password', None) kwargs.setdefault('active', True) - if username is None and email is None: - raise exceptions.UserCreationError( - 'Missing username and/or email arguments') + if email is None: + raise exceptions.UserCreationError('Missing email argument') if password is None: raise exceptions.UserCreationError('Missing password argument') @@ -129,7 +89,7 @@ def with_id(self, id): def find_user(self, user): """Returns a user based on the specified identifier. - :param user: User identifier, usually a username or email address + :param user: User identifier, usually email address """ user = self._do_find_user(user) if user: @@ -150,7 +110,6 @@ def create_role(self, **kwargs): """Creates and returns a new role. :param name: Role name - :param description: Role description """ role = self.role_model(**self._prepare_create_role_args(kwargs)) return self._save_model(role) @@ -158,7 +117,6 @@ def create_role(self, **kwargs): def create_user(self, **kwargs): """Creates and returns a new user. - :param username: Username :param email: Email address :param password: Unencrypted password :param active: The optional active state @@ -224,7 +182,6 @@ class Role(db.Model, RoleMixin): class User(db.Model, UserMixin): id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(255), unique=True) email = db.Column(db.String(255), unique=True) password = db.Column(db.String(120)) first_name = db.Column(db.String(120)) @@ -247,8 +204,7 @@ def _do_with_id(self, id): return self.user_model.query.get(id) def _do_find_user(self, user): - return self.user_model.query.filter_by(username=user).first() or \ - self.user_model.query.filter_by(email=user).first() + return self.user_model.query.filter_by(email=user).first() def _do_find_role(self, role): return self.role_model.query.filter_by(name=role).first() @@ -274,7 +230,6 @@ class Role(db.Document, RoleMixin): name = db.StringField(required=True, unique=True, max_length=80) class User(db.Document, UserMixin): - username = db.StringField(unique=True, max_length=255) email = db.StringField(unique=True, max_length=255) password = db.StringField(required=True, max_length=120) active = db.BooleanField(default=True) @@ -294,8 +249,7 @@ def _do_with_id(self, id): return None def _do_find_user(self, user): - return self.user_model.objects(username=user).first() or \ - self.user_model.objects(email=user).first() + return self.user_model.objects(email=user).first() def _do_find_role(self, role): return self.role_model.objects(name=role).first() diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py new file mode 100644 index 00000000..cee2a0fe --- /dev/null +++ b/flask_security/recoverable.py @@ -0,0 +1,34 @@ + +from datetime import datetime, timedelta + +from flask import current_app +from flask.ext.security.utils import generate_token + + +def reset_password_period_valid(user): + sent_at = user.reset_password_sent_at + reset_within = int(current_app.security.reset_password_within) + days_ago = datetime.utcnow() - timedelta(days=reset_within) + + return (sent_at is not None) and \ + (sent_at >= days_ago) + + +def generate_reset_password_token(user): + user.reset_password_token = generate_token() + user.reset_password_sent_at = datetime.utcnow() + current_app.security.datastore._save_model(user) + + +def clear_reset_password_token(user): + user.reset_password_token = None + user.reset_password_sent_at = None + + +def send_reset_password_instructions(): + pass + + +def should_generate_reset_token(user): + return (user.reset_password_token is None) or \ + (not reset_password_period_valid(user)) diff --git a/flask_security/script.py b/flask_security/script.py deleted file mode 100644 index a3207669..00000000 --- a/flask_security/script.py +++ /dev/null @@ -1,102 +0,0 @@ -# -*- coding: utf-8 -*- -""" - flask.ext.security.script - ~~~~~~~~~~~~~~~~~~~~~~~~~ - - This module contains commands for use with the Flask-Script extension - - :copyright: (c) 2012 by Matt Wright. - :license: MIT, see LICENSE for more details. -""" - -import json -import re -from flask.ext.script import Command, Option -from flask.ext.security import (UserCreationError, UserNotFoundError, - RoleNotFoundError, user_datastore) - -def pprint(obj): - print json.dumps(obj, sort_keys=True, indent=4) - - -class CreateUserCommand(Command): - """Create a user""" - - option_list = ( - Option('-u', '--username', dest='username', default=None), - Option('-e', '--email', dest='email', default=None), - Option('-p', '--password', dest='password', default=None), - Option('-a', '--active', dest='active', default=''), - Option('-r', '--roles', dest='roles', default=''), - ) - - def run(self, **kwargs): - # sanitize active input - ai = re.sub(r'\s', '', str(kwargs['active'])) - kwargs['active'] = ai.lower() in ['', 'y','yes', '1', 'active'] - - # sanitize role input a bit - ri = re.sub(r'\s', '', kwargs['roles']) - kwargs['roles'] = [] if ri == '' else ri.split(',') - - user_datastore.create_user(**kwargs) - - print 'User created successfully.' - kwargs['password'] = '****' - pprint(kwargs) - - -class CreateRoleCommand(Command): - """Create a role""" - - option_list = ( - Option('-n', '--name', dest='name', default=None), - Option('-d', '--desc', dest='description', default=None), - ) - - def run(self, **kwargs): - role = user_datastore.create_role(**kwargs) - print 'Role "%(name)s" created successfully.' % kwargs - - -class _RoleCommand(Command): - option_list = ( - Option('-u', '--user', dest='user_identifier'), - Option('-r', '--role', dest='role_name'), - ) - - -class AddRoleCommand(_RoleCommand): - """Add a role to a user""" - - def run(self, user_identifier, role_name): - user_datastore.add_role_to_user(user_identifier, role_name) - print "Role '%s' added to user '%s' successfully" % (role_name, user_identifier) - - -class RemoveRoleCommand(_RoleCommand): - """Add a role to a user""" - - def run(self, user_identifier, role_name): - user_datastore.remove_role_from_user(user_identifier, role_name) - print "Role '%s' removed from user '%s' successfully" % (role_name, user_identifier) - - -class _ToggleActiveCommand(Command): - option_list = ( - Option('-u', '--user', dest='user_identifier'), - ) - -class DeactivateUserCommand(_ToggleActiveCommand): - """Deactive a user""" - - def run(self, user_identifier): - user_datastore.deactivate_user(user_identifier) - print "User '%s' has been deactivated" % user_identifier - -class ActivateUserCommand(_ToggleActiveCommand): - """Deactive a user""" - - def run(self, user_identifier): - user_datastore.activate_user(user_identifier) - print "User '%s' has been activated" % user_identifier \ No newline at end of file diff --git a/flask_security/templates/confirmed.html b/flask_security/templates/confirmed.html new file mode 100644 index 00000000..e69de29b diff --git a/flask_security/templates/email/confirmation_instructions.html b/flask_security/templates/email/confirmation_instructions.html new file mode 100644 index 00000000..3f7c8407 --- /dev/null +++ b/flask_security/templates/email/confirmation_instructions.html @@ -0,0 +1,5 @@ +

    Welcome {{ user.email }}!

    + +

    You can confirm your account email through the link below:

    + +

    Confirm my account

    \ No newline at end of file diff --git a/flask_security/templates/email/confirmation_instructions.txt b/flask_security/templates/email/confirmation_instructions.txt new file mode 100644 index 00000000..c2534603 --- /dev/null +++ b/flask_security/templates/email/confirmation_instructions.txt @@ -0,0 +1,5 @@ +Welcome {{ user.email }}! + +You can confirm your account email through the link below: + +{{ confirmation_link }} \ No newline at end of file diff --git a/flask_security/templates/login.html b/flask_security/templates/login.html new file mode 100644 index 00000000..e69de29b diff --git a/flask_security/utils.py b/flask_security/utils.py index dc754bb2..5a4bd979 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -9,11 +9,18 @@ :license: MIT, see LICENSE for more details. """ +import base64 +import os + from importlib import import_module from flask import url_for, flash, current_app, request, session +def generate_token(): + return base64.urlsafe_b64encode(os.urandom(30)) + + def do_flash(message, category): if config_value(current_app, 'FLASH_MESSAGES'): flash(message, category) diff --git a/flask_security/views.py b/flask_security/views.py index 1c6632d4..65402573 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -12,45 +12,86 @@ from flask import current_app, redirect, request, session from flask.ext.login import login_user, logout_user from flask.ext.principal import Identity, AnonymousIdentity, identity_changed -from flask.ext.security import exceptions, utils +from flask.ext.security import exceptions, utils, confirmable +from werkzeug.local import LocalProxy + + +security = LocalProxy(lambda: current_app.security) +logger = LocalProxy(lambda: current_app.logger) + + +def do_login(user, remember=True): + if login_user(user, remember): + identity_changed.send(current_app._get_current_object(), + identity=Identity(user.id)) + logger.debug('User %s logged in' % user) + return True + return False def authenticate(): + form = current_app.security.LoginForm() try: - form = current_app.security.form_class() - user = current_app.security.auth_provider.authenticate(form) + user = security.auth_provider.authenticate(form) - if login_user(user, remember=form.remember.data): - redirect_url = utils.get_post_login_redirect() - identity_changed.send(current_app._get_current_object(), - identity=Identity(user.id)) - current_app.logger.debug('User %s logged in. Redirecting to: ' - '%s' % (user, redirect_url)) - return redirect(redirect_url) + if do_login(user, remember=form.remember.data): + url = utils.get_post_login_redirect() + return redirect(url) raise exceptions.BadCredentialsError('Inactive user') except exceptions.BadCredentialsError, e: - message = '%s' % e - utils.do_flash(message, 'error') - redirect_url = request.referrer or \ - current_app.security.login_manager.login_view - current_app.logger.error('Unsuccessful authentication attempt: %s. ' - 'Redirect to: %s' % (message, redirect_url)) - return redirect(redirect_url) + msg = str(e) + utils.do_flash(msg, 'error') + url = request.referrer or security.login_manager.login_view + + logger.debug('Unsuccessful authentication attempt: %s. ' + 'Redirect to: %s' % (msg, url)) + + return redirect(url) def logout(): - for value in ('identity.name', 'identity.auth_type'): - session.pop(value, None) + for key in ('identity.name', 'identity.auth_type'): + session.pop(key, None) - identity_changed.send(current_app._get_current_object(), - identity=AnonymousIdentity()) + app = current_app._get_current_object() + identity_changed.send(app, identity=AnonymousIdentity()) logout_user() - redirect_url = utils.find_redirect('SECURITY_POST_LOGOUT_VIEW') - current_app.logger.debug('User logged out. Redirect to: %s' % redirect_url) - return redirect(redirect_url) + url = security.post_logout_view + logger.debug('User logged out. Redirect to: %s' % url) + return redirect(url) + + +def register(): + form = security.RegisterForm(csrf_enabled=not current_app.testing) + + if form.validate_on_submit(): + params = form.to_dict() + params['roles'] = security.default_roles + params['active'] = True + + if security.confirm_email: + confirmable.generate_confirmation_token(params) + + user = security.datastore.create_user(**params) + + if security.confirm_email: + confirmable.send_confirmation_instructions(user) + + if security.login_without_confirmation: + do_login(user) + + url = security.post_register_view + logger.debug("User %s registered. Redirect to: %s" % (user, url)) + return redirect(url) + + return redirect(request.referrer or security.register_url) + + +def confirm(): + token = request.args.get('confirmation_token', None) def reset(): diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 290229d7..40def55a 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -1,4 +1,7 @@ +# -*- coding: utf-8 -*- + import unittest + from example import app @@ -25,12 +28,14 @@ def _get(self, route, content_type=None, follow_redirects=None): def _post(self, route, data=None, content_type=None, follow_redirects=True): return self.client.post(route, data=data, follow_redirects=follow_redirects, - content_type=content_type or 'text/html') + content_type=content_type or 'application/x-www-form-urlencoded') - def authenticate(self, username, password, endpoint=None): - data = dict(username=username, password=password) - return self._post(endpoint or '/auth', data=data, - content_type='application/x-www-form-urlencoded') + def register(self, email, password, endpoint=None): + return self._post(endpoint or '/register') + + def authenticate(self, email, password, endpoint=None): + data = dict(email=email, password=password) + return self._post(endpoint or '/auth', data=data) def logout(self, endpoint=None): return self._get(endpoint or '/logout', follow_redirects=True) @@ -43,15 +48,15 @@ def test_login_view(self): self.assertIn('Login Page', r.data) def test_authenticate(self): - r = self.authenticate("matt", "password") - self.assertIn('Home Page', r.data) + r = self.authenticate("matt@lp.com", "password") + self.assertIn('Hello matt@lp.com', r.data) def test_unprovided_username(self): r = self.authenticate("", "password") - self.assertIn("Username not provided", r.data) + self.assertIn("Email not provided", r.data) def test_unprovided_password(self): - r = self.authenticate("matt", "") + r = self.authenticate("matt@lp.com", "") self.assertIn("Password not provided", r.data) def test_invalid_user(self): @@ -59,15 +64,15 @@ def test_invalid_user(self): self.assertIn("Specified user does not exist", r.data) def test_bad_password(self): - r = self.authenticate("matt", "bogus") + r = self.authenticate("matt@lp.com", "bogus") self.assertIn("Password does not match", r.data) def test_inactive_user(self): - r = self.authenticate("tiya", "password") + r = self.authenticate("tiya@lp.com", "password") self.assertIn("Inactive user", r.data) def test_logout(self): - self.authenticate("matt", "password") + self.authenticate("matt@lp.com", "password") r = self.logout() self.assertIn('Home Page', r.data) @@ -76,28 +81,28 @@ def test_unauthorized_access(self): self.assertIn('Please log in to access this page', r.data) def test_authorized_access(self): - self.authenticate("matt", "password") + self.authenticate("matt@lp.com", "password") r = self._get("/profile") self.assertIn('profile', r.data) def test_valid_admin_role(self): - self.authenticate("matt", "password") + self.authenticate("matt@lp.com", "password") r = self._get("/admin") self.assertIn('Admin Page', r.data) def test_invalid_admin_role(self): - self.authenticate("joe", "password") + self.authenticate("joe@lp.com", "password") r = self._get("/admin", follow_redirects=True) self.assertIn('Home Page', r.data) def test_roles_accepted(self): - for user in ("matt", "joe"): + for user in ("matt@lp.com", "joe@lp.com"): self.authenticate(user, "password") r = self._get("/admin_or_editor") self.assertIn('Admin or Editor Page', r.data) self.logout() - self.authenticate("jill", "password") + self.authenticate("jill@lp.com", "password") r = self._get("/admin_or_editor", follow_redirects=True) self.assertIn('Home Page', r.data) @@ -105,17 +110,22 @@ def test_unauthenticated_role_required(self): r = self._get('/admin', follow_redirects=True) self.assertIn(' Date: Fri, 11 May 2012 13:57:35 -0400 Subject: [PATCH 010/234] Make datastore find_user method accept different params so a user can be retrieved by other such things such as a confirmation token --- flask_security/confirmable.py | 37 ++++++++++++-------------- flask_security/core.py | 4 +-- flask_security/datastore.py | 16 +++++------ flask_security/templates/login.html | 8 ++++++ flask_security/templates/register.html | 7 +++++ flask_security/utils.py | 15 ++++++++++- 6 files changed, 56 insertions(+), 31 deletions(-) create mode 100644 flask_security/templates/register.html diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 5a9cf85c..64ea932e 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -1,37 +1,34 @@ from datetime import datetime -from flask import render_template, current_app, request, url_for -from flask.ext.security.utils import generate_token +from flask import current_app, request, url_for +from flask.ext.security.exceptions import UserNotFoundError +from flask.ext.security.utils import generate_token, send_mail from werkzeug.local import LocalProxy - +security = LocalProxy(lambda: current_app.security) logger = LocalProxy(lambda: current_app.logger) def send_confirmation_instructions(user): - from flask.ext.mail import Message - - msg = Message("Please confirm your email", - sender=current_app.security.email_sender, - recipients=[user.email]) - - confirmation_link = request.url_root[:-1] + \ - url_for('flask_security.confirm', - confirmation_token=user.confirmation_token) + url = url_for('flask_security.confirm', + confirmation_token=user.confirmation_token) - ctx = dict(user=user, confirmation_link=confirmation_link) - msg.body = render_template('email/confirmation_instructions.txt', **ctx) - msg.html = render_template('email/confirmation_instructions.html', **ctx) + confirmation_link = request.url_root[:-1] + url - logger.debug("Sending confirmation instructions") - logger.debug(msg.html) - - current_app.mail.send(msg) + send_mail('Please confirm your email', user.email, + 'confirmation_instructions', + dict(user=user, confirmation_link=confirmation_link)) def generate_confirmation_token(user): - token = generate_token() + while True: + token = generate_token() + try: + security.datastore.find_user(confirmation_token=token) + except UserNotFoundError: + break + now = datetime.utcnow() if isinstance(user, dict): diff --git a/flask_security/core.py b/flask_security/core.py index 95d55e54..9e1736a7 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -337,7 +337,7 @@ def authenticate(self, form): return self.do_authenticate(form.email.data, form.password.data) - def do_authenticate(self, user_identifier, password): + def do_authenticate(self, email, password): """Returns the authenticated user if authentication is successfull. If authentication fails an appropriate error is raised @@ -345,7 +345,7 @@ def do_authenticate(self, user_identifier, password): :param password: The user's unencrypted password """ try: - user = current_app.security.datastore.find_user(user_identifier) + user = current_app.security.datastore.find_user(email=email) except AttributeError, e: self.auth_error("Could not find user datastore: %s" % e) except exceptions.UserNotFoundError, e: diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 87b65871..1c3d3c60 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -36,11 +36,11 @@ def _do_with_id(self, id): raise NotImplementedError( "User datastore does not implement _do_with_id method") - def _do_find_user(self): + def _do_find_user(self, **kwargs): raise NotImplementedError( "User datastore does not implement _do_find_user method") - def _do_find_role(self): + def _do_find_role(self, **kwargs): raise NotImplementedError( "User datastore does not implement _do_find_role method") @@ -86,12 +86,12 @@ def with_id(self, id): return user raise exceptions.UserIdNotFoundError() - def find_user(self, user): + def find_user(self, **kwargs): """Returns a user based on the specified identifier. :param user: User identifier, usually email address """ - user = self._do_find_user(user) + user = self._do_find_user(**kwargs) if user: return user raise exceptions.UserNotFoundError() @@ -203,8 +203,8 @@ def _save_model(self, model): def _do_with_id(self, id): return self.user_model.query.get(id) - def _do_find_user(self, user): - return self.user_model.query.filter_by(email=user).first() + def _do_find_user(self, **kwargs): + return self.user_model.query.filter_by(**kwargs).first() def _do_find_role(self, role): return self.role_model.query.filter_by(name=role).first() @@ -248,8 +248,8 @@ def _do_with_id(self, id): except: return None - def _do_find_user(self, user): - return self.user_model.objects(email=user).first() + def _do_find_user(self, **kwargs): + return self.user_model.objects(**kwargs).first() def _do_find_role(self, role): return self.role_model.objects(name=role).first() diff --git a/flask_security/templates/login.html b/flask_security/templates/login.html index e69de29b..28d979c2 100644 --- a/flask_security/templates/login.html +++ b/flask_security/templates/login.html @@ -0,0 +1,8 @@ +
    + {{ form.hidden_tag() }} + {{ form.email.label }} {{ form.email }}
    + {{ form.password.label }} {{ form.password }}
    + {{ form.remember.label }} {{ form.remember }}
    + {{ form.next }} + {{ form.submit }} +
    \ No newline at end of file diff --git a/flask_security/templates/register.html b/flask_security/templates/register.html new file mode 100644 index 00000000..c875955c --- /dev/null +++ b/flask_security/templates/register.html @@ -0,0 +1,7 @@ +
    + {{ form.hidden_tag() }} + {{ form.email.label }} {{ form.email }}
    + {{ form.password.label }} {{ form.password }}
    + {{ form.password_confirm.label }} {{ form.password_confirm }}
    + {{ form.submit }} +
    \ No newline at end of file diff --git a/flask_security/utils.py b/flask_security/utils.py index 5a4bd979..ca9ae6ba 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -14,7 +14,7 @@ from importlib import import_module -from flask import url_for, flash, current_app, request, session +from flask import url_for, flash, current_app, request, session, render_template def generate_token(): @@ -63,3 +63,16 @@ def find_redirect(key): def config_value(app, key, default=None): return app.config.get('SECURITY_' + key.upper(), default) + + +def send_mail(subject, recipient, template, context): + from flask.ext.mail import Message + + msg = Message(subject, + sender=current_app.security.email_sender, + recipients=[recipient]) + + msg.body = render_template('email/%s.txt' % template, **context) + msg.html = render_template('email/%s.html' % template, **context) + + current_app.mail.send(msg) From 4c1a16e2ee102f58ed70d180224d1cf51651f81a Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 11 May 2012 18:15:46 -0400 Subject: [PATCH 011/234] Added register signal, some testing utils and basic confirmation --- example/app.py | 3 +- flask_security/confirmable.py | 30 ++++++++++++++-- flask_security/core.py | 5 ++- flask_security/datastore.py | 1 + flask_security/exceptions.py | 14 ++++++++ flask_security/signals.py | 5 +++ flask_security/utils.py | 21 +++++++++++ flask_security/views.py | 34 ++++++++++++++++-- tests/__init__.py | 0 tests/functional_tests.py | 65 +++++++++++++++++++++++++++++------ 10 files changed, 161 insertions(+), 17 deletions(-) create mode 100644 flask_security/signals.py create mode 100644 tests/__init__.py diff --git a/example/app.py b/example/app.py index a2c8ff85..02c6810e 100644 --- a/example/app.py +++ b/example/app.py @@ -93,7 +93,7 @@ def admin_or_editor(): def create_sqlalchemy_app(auth_config=None): app = create_app(auth_config) - app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/flask_security_example.sqlite' + app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://root@localhost/flask_security_test' db = SQLAlchemy(app) @@ -113,6 +113,7 @@ class User(db.Model, UserMixin): active = db.Column(db.Boolean()) confirmation_token = db.Column(db.String(255)) confirmation_sent_at = db.Column(db.DateTime()) + confirmed_at = db.Column(db.DateTime()) roles = db.relationship('Role', secondary=roles_users, backref=db.backref('users', lazy='dynamic')) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 64ea932e..3253da22 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -2,7 +2,8 @@ from datetime import datetime from flask import current_app, request, url_for -from flask.ext.security.exceptions import UserNotFoundError +from flask.ext.security.exceptions import UserNotFoundError, \ + ConfirmationError, ConfirmationExpiredError from flask.ext.security.utils import generate_token, send_mail from werkzeug.local import LocalProxy @@ -10,6 +11,12 @@ logger = LocalProxy(lambda: current_app.logger) +def find_user_by_confirmation_token(token): + if not token: + raise ConfirmationError('Unknown confirmation token') + return security.datastore.find_user(confirmation_token=token) + + def send_confirmation_instructions(user): url = url_for('flask_security.confirm', confirmation_token=user.confirmation_token) @@ -20,12 +27,14 @@ def send_confirmation_instructions(user): 'confirmation_instructions', dict(user=user, confirmation_link=confirmation_link)) + return True + def generate_confirmation_token(user): while True: token = generate_token() try: - security.datastore.find_user(confirmation_token=token) + find_user_by_confirmation_token(token) except UserNotFoundError: break @@ -39,3 +48,20 @@ def generate_confirmation_token(user): user.confirmation_sent_at = now return user + + +def confirm_by_token(token): + now = datetime.utcnow() + user = find_user_by_confirmation_token(token) + + token_expires = now - security.confirm_email_within + + if user.confirmation_sent_at < token_expires: + raise ConfirmationExpiredError('Confirmation token is expired', user=user) + + user.confirmed_at = now + user.confirmation_token = None + user.confirmation_sent_at = None + security.datastore._save_model(user) + + return user diff --git a/flask_security/core.py b/flask_security/core.py index 9e1736a7..c3bd9940 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -42,6 +42,7 @@ 'SECURITY_POST_LOGIN_VIEW': '/', 'SECURITY_POST_LOGOUT_VIEW': '/', 'SECURITY_POST_REGISTER_VIEW': '/', + 'SECURITY_POST_CONFIRM_VIEW': '/', 'SECURITY_RESET_PASSWORD_WITHIN': 10, 'SECURITY_DEFAULT_ROLES': [], 'SECURITY_LOGIN_WITHOUT_CONFIRMATION': True, @@ -234,13 +235,15 @@ def init_app(self, app, datastore, self.post_login_view = utils.config_value(app, 'POST_LOGIN_VIEW') self.post_logout_view = utils.config_value(app, 'POST_LOGOUT_VIEW') self.post_register_view = utils.config_value(app, 'POST_REGISTER_VIEW') + self.post_confirm_view = utils.config_value(app, 'POST_CONFIRM_VIEW') self.reset_password_within = utils.config_value(app, 'RESET_PASSWORD_WITHIN') self.default_roles = utils.config_value(app, "DEFAULT_ROLES") self.login_without_confirmation = utils.config_value(app, 'LOGIN_WITHOUT_CONFIRMATION') self.confirm_email = utils.config_value(app, 'CONFIRM_EMAIL') self.email_sender = utils.config_value(app, 'EMAIL_SENDER') + self.confirm_email_within_text = utils.config_value(app, 'CONFIRM_EMAIL_WITHIN') - values = utils.config_value(app, 'CONFIRM_EMAIL_WITHIN').split() + values = self.confirm_email_within_text.split() self.confirm_email_within = timedelta(**{values[1]: int(values[0])}) identity_loaded.connect_via(app)(on_identity_loaded) diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 1c3d3c60..6bab2e0d 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -91,6 +91,7 @@ def find_user(self, **kwargs): :param user: User identifier, usually email address """ + print kwargs user = self._do_find_user(**kwargs) if user: return user diff --git a/flask_security/exceptions.py b/flask_security/exceptions.py index f15d4eaf..996fa3b5 100644 --- a/flask_security/exceptions.py +++ b/flask_security/exceptions.py @@ -53,3 +53,17 @@ class UserCreationError(Exception): class RoleCreationError(Exception): """Raised when an error occurs when creating a role """ + + +class ConfirmationError(Exception): + """Raised when an unknown confirmation error occurs + """ + + +class ConfirmationExpiredError(Exception): + """Raised when a user attempts to confirm their email but their token + has expired + """ + def __init__(self, msg, user=None): + super(ConfirmationExpiredError, self).__init__(msg) + self.user = user diff --git a/flask_security/signals.py b/flask_security/signals.py new file mode 100644 index 00000000..6c257ad3 --- /dev/null +++ b/flask_security/signals.py @@ -0,0 +1,5 @@ +import blinker + +signals = blinker.Namespace() + +user_registered = signals.signal("user-register") diff --git a/flask_security/utils.py b/flask_security/utils.py index ca9ae6ba..071f5abe 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -12,9 +12,11 @@ import base64 import os +from contextlib import contextmanager from importlib import import_module from flask import url_for, flash, current_app, request, session, render_template +from flask.ext.security.signals import user_registered def generate_token(): @@ -76,3 +78,22 @@ def send_mail(subject, recipient, template, context): msg.html = render_template('email/%s.html' % template, **context) current_app.mail.send(msg) + + +@contextmanager +def capture_registrations(confirmation_sent_at=None): + users = [] + + def _on(user, app): + if confirmation_sent_at: + user.confirmation_sent_at = confirmation_sent_at + current_app.security.datastore._save_model(user) + + users.append(user) + + user_registered.connect(_on) + + try: + yield users + finally: + user_registered.disconnect(_on) diff --git a/flask_security/views.py b/flask_security/views.py index 65402573..7d09bb3f 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -12,11 +12,12 @@ from flask import current_app, redirect, request, session from flask.ext.login import login_user, logout_user from flask.ext.principal import Identity, AnonymousIdentity, identity_changed -from flask.ext.security import exceptions, utils, confirmable +from flask.ext.security import exceptions, utils, confirmable, signals from werkzeug.local import LocalProxy security = LocalProxy(lambda: current_app.security) + logger = LocalProxy(lambda: current_app.logger) @@ -77,6 +78,9 @@ def register(): user = security.datastore.create_user(**params) + app = current_app._get_current_object() + signals.user_registered.send(user, app=app) + if security.confirm_email: confirmable.send_confirmation_instructions(user) @@ -84,14 +88,38 @@ def register(): do_login(user) url = security.post_register_view - logger.debug("User %s registered. Redirect to: %s" % (user, url)) + logger.debug('User %s registered. Redirect to: %s' % (user, url)) return redirect(url) return redirect(request.referrer or security.register_url) def confirm(): - token = request.args.get('confirmation_token', None) + try: + token = request.args.get('confirmation_token', None) + user = confirmable.confirm_by_token(token) + + except exceptions.ConfirmationError, e: + utils.do_flash(str(e), 'error') + return redirect('/') # TODO: Don't just redirect to root + + except exceptions.ConfirmationExpiredError, e: + user = e.user + confirmable.generate_confirmation_token(user) + confirmable.send_confirmation_instructions(user) + + msg = 'You did not confirm your email within %s. ' \ + 'A new confirmation code has been sent to %s' % ( + security.confirm_email_within_text, user.email) + + utils.do_flash(msg, 'error') + + return redirect('/') + + do_login(user) + utils.do_flash('Thank you! Your email has been confirmed', 'success') + + return redirect(security.post_confirm_view or security.post_login_view) def reset(): diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 40def55a..8eca04b9 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- import unittest +from datetime import datetime, timedelta + +from flask.ext.security.utils import capture_registrations from example import app @@ -149,6 +152,58 @@ def test_register(self): class ConfirmationTests(SecurityTest): + AUTH_CONFIG = { + 'SECURITY_CONFIRM_EMAIL': True + } + + def register(self, email, password='password'): + data = dict(email=email, password=password, password_confirm=password) + return self.client.post('/register', data=data, follow_redirects=True) + + def test_register_sends_confirmation_email(self): + e = 'dude@lp.com' + with self.app.mail.record_messages() as outbox: + self.register(e) + self.assertEqual(len(outbox), 1) + self.assertIn(e, outbox[0].html) + + def test_confirm_email(self): + e = 'dude@lp.com' + + with capture_registrations() as users: + self.register(e) + token = users[0].confirmation_token + + r = self.client.get('/confirm?confirmation_token=' + token, follow_redirects=True) + self.assertIn('Thank you! Your email has been confirmed', r.data) + + def test_invalid_or_unprovided_token_when_confirming_email(self): + r = self.client.get('/confirm', follow_redirects=True) + self.assertIn('Unknown confirmation token', r.data) + + def test_expired_confirmation_token_sends_email(self): + e = 'dude@lp.com' + + sent_at = datetime.utcnow() - timedelta(days=15) + + with capture_registrations(confirmation_sent_at=sent_at) as users: + self.register(e) + token = users[0].confirmation_token + + with self.app.mail.record_messages() as outbox: + r = self.client.get('/confirm?confirmation_token=' + token, follow_redirects=True) + + self.assertEqual(len(outbox), 1) + self.assertIn(e, outbox[0].html) + self.assertNotIn(token, outbox[0].html) + + expire_text = self.app.security.confirm_email_within_text + text = 'You did not confirm your email within %s' % expire_text + + self.assertIn(text, r.data) + + +class ConfirmationAndCanLoginTests(SecurityTest): AUTH_CONFIG = { 'SECURITY_CONFIRM_EMAIL': True, 'SECURITY_LOGIN_WITHOUT_CONFIRMATION': True @@ -161,16 +216,6 @@ def test_register_valid_user_automatically_signs_in(self): r = self.client.post('/register', data=data, follow_redirects=True) self.assertIn(e, r.data) - def test_register_valid_user_sends_confirmation_email(self): - e = 'dude@lp.com' - p = 'password' - data = dict(email=e, password=p, password_confirm=p) - - with self.app.mail.record_messages() as outbox: - self.client.post('/register', data=data, follow_redirects=True) - self.assertEqual(len(outbox), 1) - self.assertIn(e, outbox[0].html) - class MongoEngineSecurityTests(DefaultSecurityTests): From 4e7663079bbac333a355c6b5624c3811dba76231 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 14 May 2012 18:58:43 -0400 Subject: [PATCH 012/234] Refactor some methods --- example/app.py | 3 ++- flask_security/confirmable.py | 25 ++++++++++++++++++------- flask_security/datastore.py | 1 - flask_security/exceptions.py | 5 +++++ flask_security/views.py | 17 ++++++++++++----- 5 files changed, 37 insertions(+), 14 deletions(-) diff --git a/example/app.py b/example/app.py index 02c6810e..6eb33c15 100644 --- a/example/app.py +++ b/example/app.py @@ -130,7 +130,7 @@ def before_first_request(): def create_mongoengine_app(auth_config=None): app = create_app(auth_config) - app.config['MONGODB_DB'] = 'flask_security_example' + app.config['MONGODB_DB'] = 'flask_security_test' app.config['MONGODB_HOST'] = 'localhost' app.config['MONGODB_PORT'] = 27017 @@ -146,6 +146,7 @@ class User(db.Document, UserMixin): active = db.BooleanField(default=True) confirmation_token = db.StringField(max_length=255) confirmation_sent_at = db.DateTimeField() + confirmed_at = db.DateTimeField() roles = db.ListField(db.ReferenceField(Role), default=[]) Security(app, MongoEngineUserDatastore(db, User, Role)) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 3253da22..b52de607 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -50,18 +50,29 @@ def generate_confirmation_token(user): return user -def confirm_by_token(token): - now = datetime.utcnow() - user = find_user_by_confirmation_token(token) +def requires_confirmation(user): + return (security.confirm_email and \ + not security.login_without_confirmation and \ + not confirmation_token_is_expired(user)) - token_expires = now - security.confirm_email_within +def confirmation_token_is_expired(user): + token_expires = datetime.utcnow() - security.confirm_email_within if user.confirmation_sent_at < token_expires: + return True + return False + + +def confirm_by_token(token): + user = find_user_by_confirmation_token(token) + + if confirmation_token_is_expired(user): raise ConfirmationExpiredError('Confirmation token is expired', user=user) - user.confirmed_at = now - user.confirmation_token = None - user.confirmation_sent_at = None + user.confirmed_at = datetime.utcnow() + #user.confirmation_token = None + #user.confirmation_sent_at = None + security.datastore._save_model(user) return user diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 6bab2e0d..1c3d3c60 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -91,7 +91,6 @@ def find_user(self, **kwargs): :param user: User identifier, usually email address """ - print kwargs user = self._do_find_user(**kwargs) if user: return user diff --git a/flask_security/exceptions.py b/flask_security/exceptions.py index 996fa3b5..6ef84f80 100644 --- a/flask_security/exceptions.py +++ b/flask_security/exceptions.py @@ -67,3 +67,8 @@ class ConfirmationExpiredError(Exception): def __init__(self, msg, user=None): super(ConfirmationExpiredError, self).__init__(msg) self.user = user + + +class ConfirmationRequiredError(Exception): + """Raised when a user attempts to login but requires confirmation + """ diff --git a/flask_security/views.py b/flask_security/views.py index 7d09bb3f..56ce9a49 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -22,6 +22,9 @@ def do_login(user, remember=True): + if confirmable.requires_confirmation(user): + raise exceptions.ConfirmationRequiredError() + if login_user(user, remember): identity_changed.send(current_app._get_current_object(), identity=Identity(user.id)) @@ -41,15 +44,19 @@ def authenticate(): raise exceptions.BadCredentialsError('Inactive user') + except exceptions.ConfirmationRequiredError, e: + msg = str(e) + except exceptions.BadCredentialsError, e: msg = str(e) - utils.do_flash(msg, 'error') - url = request.referrer or security.login_manager.login_view - logger.debug('Unsuccessful authentication attempt: %s. ' - 'Redirect to: %s' % (msg, url)) + utils.do_flash(msg, 'error') + url = request.referrer or security.login_manager.login_view - return redirect(url) + logger.debug('Unsuccessful authentication attempt: %s. ' + 'Redirect to: %s' % (msg, url)) + + return redirect(url) def logout(): From 4f61f58b0da753b8db7891d48e7bfd17d802ee33 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 15 May 2012 17:02:04 -0400 Subject: [PATCH 013/234] Polish --- flask_security/confirmable.py | 30 +++++--- flask_security/core.py | 4 +- flask_security/datastore.py | 7 +- flask_security/exceptions.py | 7 +- flask_security/views.py | 128 ++++++++++++++++++++-------------- 5 files changed, 111 insertions(+), 65 deletions(-) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index b52de607..c4ba6933 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -40,34 +40,41 @@ def generate_confirmation_token(user): now = datetime.utcnow() - if isinstance(user, dict): + try: user['confirmation_token'] = token user['confirmation_sent_at'] = now - else: + except TypeError: user.confirmation_token = token user.confirmation_sent_at = now return user +def should_confirm_email(fn): + def wrapped(*args, **kwargs): + if security.confirm_email: + return fn(*args, **kwargs) + return False + return wrapped + + +@should_confirm_email def requires_confirmation(user): - return (security.confirm_email and \ - not security.login_without_confirmation and \ - not confirmation_token_is_expired(user)) + return not security.login_without_confirmation and \ + not confirmation_token_is_expired(user) +@should_confirm_email def confirmation_token_is_expired(user): token_expires = datetime.utcnow() - security.confirm_email_within - if user.confirmation_sent_at < token_expires: - return True - return False + return user.confirmation_sent_at < token_expires def confirm_by_token(token): user = find_user_by_confirmation_token(token) if confirmation_token_is_expired(user): - raise ConfirmationExpiredError('Confirmation token is expired', user=user) + raise ConfirmationExpiredError(user=user) user.confirmed_at = datetime.utcnow() #user.confirmation_token = None @@ -76,3 +83,8 @@ def confirm_by_token(token): security.datastore._save_model(user) return user + + +def reset_confirmation_token(user): + security.datastore._save_model(generate_confirmation_token(user)) + send_confirmation_instructions(user) diff --git a/flask_security/core.py b/flask_security/core.py index c3bd9940..d7aa7aa9 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -41,8 +41,8 @@ 'SECURITY_LOGIN_VIEW': '/login', 'SECURITY_POST_LOGIN_VIEW': '/', 'SECURITY_POST_LOGOUT_VIEW': '/', - 'SECURITY_POST_REGISTER_VIEW': '/', - 'SECURITY_POST_CONFIRM_VIEW': '/', + 'SECURITY_POST_REGISTER_VIEW': None, + 'SECURITY_POST_CONFIRM_VIEW': None, 'SECURITY_RESET_PASSWORD_WITHIN': 10, 'SECURITY_DEFAULT_ROLES': [], 'SECURITY_LOGIN_WITHOUT_CONFIRMATION': True, diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 1c3d3c60..4e5ef969 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -10,7 +10,7 @@ """ from flask import current_app -from flask.ext.security import exceptions +from flask.ext.security import exceptions, confirmable class UserDatastore(object): @@ -53,7 +53,12 @@ def _prepare_create_role_args(self, kwargs): def _prepare_create_user_args(self, kwargs): email = kwargs.get('email', None) password = kwargs.get('password', None) + kwargs.setdefault('active', True) + kwargs.setdefault('roles', current_app.security.default_roles) + + if current_app.security.confirm_email: + confirmable.generate_confirmation_token(kwargs) if email is None: raise exceptions.UserCreationError('Missing email argument') diff --git a/flask_security/exceptions.py b/flask_security/exceptions.py index 6ef84f80..3b00fac5 100644 --- a/flask_security/exceptions.py +++ b/flask_security/exceptions.py @@ -64,11 +64,14 @@ class ConfirmationExpiredError(Exception): """Raised when a user attempts to confirm their email but their token has expired """ - def __init__(self, msg, user=None): - super(ConfirmationExpiredError, self).__init__(msg) + def __init__(self, user=None): + super(ConfirmationExpiredError, self).__init__() self.user = user class ConfirmationRequiredError(Exception): """Raised when a user attempts to login but requires confirmation """ + def __init__(self, user=None): + super(ConfirmationRequiredError, self).__init__() + self.user = user diff --git a/flask_security/views.py b/flask_security/views.py index 56ce9a49..c90e8eca 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -12,7 +12,13 @@ from flask import current_app, redirect, request, session from flask.ext.login import login_user, logout_user from flask.ext.principal import Identity, AnonymousIdentity, identity_changed -from flask.ext.security import exceptions, utils, confirmable, signals +from flask.ext.security.confirmable import confirmation_token_is_expired, \ + send_confirmation_instructions, generate_confirmation_token, \ + reset_confirmation_token, requires_confirmation, confirm_by_token +from flask.ext.security.exceptions import ConfirmationExpiredError, \ + ConfirmationError, BadCredentialsError +from flask.ext.security.utils import get_post_login_redirect, do_flash +from flask.ext.security.signals import user_registered from werkzeug.local import LocalProxy @@ -21,110 +27,130 @@ logger = LocalProxy(lambda: current_app.logger) -def do_login(user, remember=True): - if confirmable.requires_confirmation(user): - raise exceptions.ConfirmationRequiredError() - +def _do_login(user, remember=True): if login_user(user, remember): identity_changed.send(current_app._get_current_object(), identity=Identity(user.id)) + logger.debug('User %s logged in' % user) return True return False def authenticate(): - form = current_app.security.LoginForm() + """View function which handles an authentication attempt. If authentication + is successful the user is redirected to, if set, the value of the `next` + form parameter. If that value is not set the user is redirected to the + value of the `SECURITY_POST_LOGIN_VIEW` configuration value. If + authenticate fails the user an appropriate error message is flashed and + the user is redirected to the referring page or the login view. + """ + form = security.LoginForm() + try: user = security.auth_provider.authenticate(form) - if do_login(user, remember=form.remember.data): - url = utils.get_post_login_redirect() - return redirect(url) + if confirmation_token_is_expired(user): + reset_confirmation_token(user) - raise exceptions.BadCredentialsError('Inactive user') + if requires_confirmation(user): + raise BadCredentialsError('Account requires confirmation') - except exceptions.ConfirmationRequiredError, e: - msg = str(e) + if _do_login(user, remember=form.remember.data): + return redirect(get_post_login_redirect()) + + raise BadCredentialsError('Inactive user') - except exceptions.BadCredentialsError, e: + except BadCredentialsError, e: msg = str(e) - utils.do_flash(msg, 'error') - url = request.referrer or security.login_manager.login_view + except Exception, e: + msg = 'Uknown authentication error' - logger.debug('Unsuccessful authentication attempt: %s. ' - 'Redirect to: %s' % (msg, url)) + do_flash(msg, 'error') - return redirect(url) + logger.debug('Unsuccessful authentication attempt: %s. ' % msg) + + return redirect(request.referrer or security.login_manager.login_view) def logout(): + """View function which logs out the current user. When completed the user + is redirected to the value of the `next` query string parameter or the + `SECURITY_POST_LOGIN_VIEW` configuration value. + """ for key in ('identity.name', 'identity.auth_type'): session.pop(key, None) - app = current_app._get_current_object() - identity_changed.send(app, identity=AnonymousIdentity()) + identity_changed.send(current_app._get_current_object(), + identity=AnonymousIdentity()) + logout_user() - url = security.post_logout_view - logger.debug('User logged out. Redirect to: %s' % url) - return redirect(url) + logger.debug('User logged out') + + return redirect(request.args.get('next', None) or \ + security.post_logout_view) def register(): + """View function which registers a new user and, if configured so, the user + isautomatically logged in. If required confirmation instructions are sent + via email. After registration is completed the user is redirected to, if + set, the value of the `SECURITY_POST_REGISTER_VIEW` configuration value. + Otherwise the user is redirected to the `SECURITY_POST_LOGIN_VIEW` + configuration value. + """ form = security.RegisterForm(csrf_enabled=not current_app.testing) - if form.validate_on_submit(): - params = form.to_dict() - params['roles'] = security.default_roles - params['active'] = True - - if security.confirm_email: - confirmable.generate_confirmation_token(params) - - user = security.datastore.create_user(**params) + # Exit early if the form doesn't validate + if not form.validate_on_submit(): + return redirect(request.referrer or security.register_url) - app = current_app._get_current_object() - signals.user_registered.send(user, app=app) + # Create user and send signal + user = security.datastore.create_user(**form.to_dict()) + user_registered.send(user, app=current_app._get_current_object()) - if security.confirm_email: - confirmable.send_confirmation_instructions(user) + # Send confirmation instructions if necessary + if security.confirm_email: + send_confirmation_instructions(user) - if security.login_without_confirmation: - do_login(user) + # Login the user if allowed + if security.login_without_confirmation: + _do_login(user) - url = security.post_register_view - logger.debug('User %s registered. Redirect to: %s' % (user, url)) - return redirect(url) + logger.debug('User %s registered' % user) - return redirect(request.referrer or security.register_url) + return redirect(security.post_register_view or security.post_login_view) def confirm(): + """View function which confirms a user's email address using a token taken + from the value of the `confirmation_token` query string argument. + """ try: token = request.args.get('confirmation_token', None) - user = confirmable.confirm_by_token(token) + user = confirm_by_token(token) - except exceptions.ConfirmationError, e: - utils.do_flash(str(e), 'error') + except ConfirmationError, e: + do_flash(str(e), 'error') return redirect('/') # TODO: Don't just redirect to root - except exceptions.ConfirmationExpiredError, e: + except ConfirmationExpiredError, e: user = e.user - confirmable.generate_confirmation_token(user) - confirmable.send_confirmation_instructions(user) + generate_confirmation_token(user) + send_confirmation_instructions(user) msg = 'You did not confirm your email within %s. ' \ 'A new confirmation code has been sent to %s' % ( security.confirm_email_within_text, user.email) - utils.do_flash(msg, 'error') + do_flash(msg, 'error') return redirect('/') - do_login(user) - utils.do_flash('Thank you! Your email has been confirmed', 'success') + _do_login(user) + do_flash('Thank you! Your email has been confirmed', 'success') return redirect(security.post_confirm_view or security.post_login_view) From 903b07bfbb365da49cf4001bc854203d189ed8a2 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 15 May 2012 17:04:42 -0400 Subject: [PATCH 014/234] Polish --- flask_security/views.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/flask_security/views.py b/flask_security/views.py index c90e8eca..33d37577 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -137,13 +137,11 @@ def confirm(): return redirect('/') # TODO: Don't just redirect to root except ConfirmationExpiredError, e: - user = e.user - generate_confirmation_token(user) - send_confirmation_instructions(user) + reset_confirmation_token(e.user) msg = 'You did not confirm your email within %s. ' \ 'A new confirmation code has been sent to %s' % ( - security.confirm_email_within_text, user.email) + security.confirm_email_within_text, e.user.email) do_flash(msg, 'error') From 17ea120032d4f3476184e692d148519125267890 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 15 May 2012 17:17:38 -0400 Subject: [PATCH 015/234] Some more polish. Fix a test --- flask_security/views.py | 5 ++--- tests/functional_tests.py | 4 +++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/flask_security/views.py b/flask_security/views.py index 33d37577..b2f1ac39 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -130,7 +130,7 @@ def confirm(): """ try: token = request.args.get('confirmation_token', None) - user = confirm_by_token(token) + confirm_by_token(token) except ConfirmationError, e: do_flash(str(e), 'error') @@ -147,8 +147,7 @@ def confirm(): return redirect('/') - _do_login(user) - do_flash('Thank you! Your email has been confirmed', 'success') + do_flash('Your email has been confirmed. You may now log in.', 'success') return redirect(security.post_confirm_view or security.post_login_view) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 8eca04b9..440aac01 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -174,8 +174,10 @@ def test_confirm_email(self): self.register(e) token = users[0].confirmation_token + r = self.authenticate('dude@lp.com', 'password') + self.assertIn('Account requires confirmation', r.data) r = self.client.get('/confirm?confirmation_token=' + token, follow_redirects=True) - self.assertIn('Thank you! Your email has been confirmed', r.data) + self.assertIn('Your email has been confirmed. You may now log in.', r.data) def test_invalid_or_unprovided_token_when_confirming_email(self): r = self.client.get('/confirm', follow_redirects=True) From 0f914d1ea6eeb073c977b16e19892920218aa3f8 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 15 May 2012 18:14:28 -0400 Subject: [PATCH 016/234] Attempting more clean up --- flask_security/confirmable.py | 3 +-- flask_security/core.py | 46 +++++++++++++++++------------------ flask_security/views.py | 7 +++--- tests/functional_tests.py | 6 ++--- 4 files changed, 30 insertions(+), 32 deletions(-) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index c4ba6933..bf40ecf9 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -60,8 +60,7 @@ def wrapped(*args, **kwargs): @should_confirm_email def requires_confirmation(user): - return not security.login_without_confirmation and \ - not confirmation_token_is_expired(user) + return user.confirmed_at == None @should_confirm_email diff --git a/flask_security/core.py b/flask_security/core.py index d7aa7aa9..d53f9ce1 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -27,28 +27,28 @@ #: Default Flask-Security configuration _default_config = { - 'SECURITY_URL_PREFIX': None, - 'SECURITY_FLASH_MESSAGES': True, - 'SECURITY_PASSWORD_HASH': 'plaintext', - 'SECURITY_AUTH_PROVIDER': 'flask.ext.security::AuthenticationProvider', - 'SECURITY_LOGIN_FORM': 'flask.ext.security::LoginForm', - 'SECURITY_REGISTER_FORM': 'flask.ext.security::RegisterForm', - 'SECURITY_AUTH_URL': '/auth', - 'SECURITY_LOGOUT_URL': '/logout', - 'SECURITY_REGISTER_URL': '/register', - 'SECURITY_RESET_URL': '/reset', - 'SECURITY_CONFIRM_URL': '/confirm', - 'SECURITY_LOGIN_VIEW': '/login', - 'SECURITY_POST_LOGIN_VIEW': '/', - 'SECURITY_POST_LOGOUT_VIEW': '/', - 'SECURITY_POST_REGISTER_VIEW': None, - 'SECURITY_POST_CONFIRM_VIEW': None, - 'SECURITY_RESET_PASSWORD_WITHIN': 10, - 'SECURITY_DEFAULT_ROLES': [], - 'SECURITY_LOGIN_WITHOUT_CONFIRMATION': True, - 'SECURITY_CONFIRM_EMAIL': False, - 'SECURITY_CONFIRM_EMAIL_WITHIN': '5 days', - 'SECURITY_EMAIL_SENDER': 'no-reply@localhost' + 'URL_PREFIX': None, + 'FLASH_MESSAGES': True, + 'PASSWORD_HASH': 'plaintext', + 'AUTH_PROVIDER': 'flask.ext.security::AuthenticationProvider', + 'LOGIN_FORM': 'flask.ext.security::LoginForm', + 'REGISTER_FORM': 'flask.ext.security::RegisterForm', + 'AUTH_URL': '/auth', + 'LOGOUT_URL': '/logout', + 'REGISTER_URL': '/register', + 'RESET_URL': '/reset', + 'CONFIRM_URL': '/confirm', + 'LOGIN_VIEW': '/login', + 'POST_LOGIN_VIEW': '/', + 'POST_LOGOUT_VIEW': '/', + 'POST_REGISTER_VIEW': None, + 'POST_CONFIRM_VIEW': None, + 'RESET_PASSWORD_WITHIN': 10, + 'DEFAULT_ROLES': [], + 'LOGIN_WITHOUT_CONFIRMATION': False, + 'CONFIRM_EMAIL': False, + 'CONFIRM_EMAIL_WITHIN': '5 days', + 'EMAIL_SENDER': 'no-reply@localhost' } @@ -209,7 +209,7 @@ def init_app(self, app, datastore, return for key, value in _default_config.items(): - app.config.setdefault(key, value) + app.config.setdefault('SECURITY_' + key, value) login_manager = LoginManager() login_manager.anonymous_user = AnonymousUser diff --git a/flask_security/views.py b/flask_security/views.py index b2f1ac39..e95544ff 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -12,9 +12,9 @@ from flask import current_app, redirect, request, session from flask.ext.login import login_user, logout_user from flask.ext.principal import Identity, AnonymousIdentity, identity_changed -from flask.ext.security.confirmable import confirmation_token_is_expired, \ - send_confirmation_instructions, generate_confirmation_token, \ - reset_confirmation_token, requires_confirmation, confirm_by_token +from flask.ext.security.confirmable import confirm_by_token, \ + confirmation_token_is_expired, requires_confirmation, \ + reset_confirmation_token, send_confirmation_instructions from flask.ext.security.exceptions import ConfirmationExpiredError, \ ConfirmationError, BadCredentialsError from flask.ext.security.utils import get_post_login_redirect, do_flash @@ -50,6 +50,7 @@ def authenticate(): try: user = security.auth_provider.authenticate(form) + # Conveniently reset the token if necessary and expired if confirmation_token_is_expired(user): reset_confirmation_token(user) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 440aac01..b5e1d00f 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -115,7 +115,8 @@ def test_unauthenticated_role_required(self): def test_register_valid_user(self): data = dict(email='dude@lp.com', password='password', password_confirm='password') - self.client.post('/register', data=data, follow_redirects=True) + r = self.client.post('/register', data=data, follow_redirects=True) + self.assertNotIn('Hello dude@lp.com', r.data) r = self.authenticate('dude@lp.com', 'password') self.assertIn('Hello dude@lp.com', r.data) @@ -147,7 +148,6 @@ def test_logout(self): def test_register(self): data = dict(email='dude@lp.com', password='password', password_confirm='password') r = self.client.post('/register', data=data, follow_redirects=True) - self.assertIn('Hello dude@lp.com', r.data) self.assertIn('Post Register', r.data) @@ -174,8 +174,6 @@ def test_confirm_email(self): self.register(e) token = users[0].confirmation_token - r = self.authenticate('dude@lp.com', 'password') - self.assertIn('Account requires confirmation', r.data) r = self.client.get('/confirm?confirmation_token=' + token, follow_redirects=True) self.assertIn('Your email has been confirmed. You may now log in.', r.data) From 19cc30a3c1fc90d88b9a4e8717ce3b3e82eb3f6b Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 15 May 2012 18:43:06 -0400 Subject: [PATCH 017/234] Fix up form classes and add ResetPasswordFOrm --- flask_security/core.py | 12 ++++++++++-- flask_security/templates/reset.html | 6 ++++++ flask_security/views.py | 6 +++--- tests/functional_tests.py | 3 +-- 4 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 flask_security/templates/reset.html diff --git a/flask_security/core.py b/flask_security/core.py index d53f9ce1..79eec4e9 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -303,13 +303,21 @@ class RegisterForm(Form): validators=[Required(message='Email not provided'), Email()]) password = PasswordField("Password", validators=[Required(message="Password not provided")]) - password_confirm = PasswordField("Password", - validators=[EqualTo('password', message="Password not provided")]) + password_confirm = PasswordField("Retype Password", + validators=[EqualTo('password', message="Passwords do not match")]) def to_dict(self): return dict(email=self.email.data, password=self.password.data) +class ResetPasswordForm(Form): + token = HiddenField() + password = PasswordField("Password", + validators=[Required(message="Password not provided")]) + password_confirm = PasswordField("Retype Password", + validators=[EqualTo('password', message="Passwords do not match")]) + + class AuthenticationProvider(object): """The default authentication provider implementation. diff --git a/flask_security/templates/reset.html b/flask_security/templates/reset.html new file mode 100644 index 00000000..744448bf --- /dev/null +++ b/flask_security/templates/reset.html @@ -0,0 +1,6 @@ +
    + {{ form.hidden_tag() }} + {{ form.password.label }} {{ form.password }}
    + {{ form.password_confirm.label }} {{ form.password_confirm }}
    + {{ form.submit }} +
    \ No newline at end of file diff --git a/flask_security/views.py b/flask_security/views.py index e95544ff..8c9440c9 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -116,12 +116,12 @@ def register(): if security.confirm_email: send_confirmation_instructions(user) + logger.debug('User %s registered' % user) + # Login the user if allowed - if security.login_without_confirmation: + if (not security.confirm_email) or security.login_without_confirmation: _do_login(user) - logger.debug('User %s registered' % user) - return redirect(security.post_register_view or security.post_login_view) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index b5e1d00f..75754682 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -115,8 +115,7 @@ def test_unauthenticated_role_required(self): def test_register_valid_user(self): data = dict(email='dude@lp.com', password='password', password_confirm='password') - r = self.client.post('/register', data=data, follow_redirects=True) - self.assertNotIn('Hello dude@lp.com', r.data) + self.client.post('/register', data=data, follow_redirects=True) r = self.authenticate('dude@lp.com', 'password') self.assertIn('Hello dude@lp.com', r.data) From 67b806860e3a2f30026a7889b2b195015cd7cf3e Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 16 May 2012 12:27:43 -0400 Subject: [PATCH 018/234] Fixes #9 --- example/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/example/app.py b/example/app.py index 6eb33c15..728dff30 100644 --- a/example/app.py +++ b/example/app.py @@ -98,8 +98,8 @@ def create_sqlalchemy_app(auth_config=None): db = SQLAlchemy(app) roles_users = db.Table('roles_users', - db.Column('user_id', db.Integer(), db.ForeignKey('role.id')), - db.Column('role_id', db.Integer(), db.ForeignKey('user.id'))) + db.Column('user_id', db.Integer(), db.ForeignKey('user.id')), + db.Column('role_id', db.Integer(), db.ForeignKey('role.id'))) class Role(db.Model, RoleMixin): id = db.Column(db.Integer(), primary_key=True) From 09aa7e113c06f3a33329ba3e10a081725e4eaec4 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 22 May 2012 18:08:38 -0400 Subject: [PATCH 019/234] Heavy work on confirmation and reset --- example/app.py | 4 + example/templates/register.html | 10 +-- flask_security/confirmable.py | 17 ++-- flask_security/core.py | 44 +++++++--- flask_security/datastore.py | 2 +- flask_security/exceptions.py | 41 +++++---- flask_security/recoverable.py | 84 +++++++++++++++---- flask_security/signals.py | 4 +- flask_security/templates/confirmed.html | 0 flask_security/templates/register.html | 7 -- flask_security/templates/reset.html | 6 -- .../templates/security/confirmations/new.html | 1 + .../email/confirmation_instructions.html | 0 .../email/confirmation_instructions.txt | 0 .../security/email/reset_instructions.html | 1 + .../security/email/reset_instructions.txt | 3 + .../security/email/reset_notice.html | 1 + .../templates/security/email/reset_notice.txt | 1 + .../{login.html => security/logins/new.html} | 0 .../templates/security/passwords/edit.html | 6 ++ .../templates/security/passwords/new.html | 5 ++ .../security/registrations/edit.html | 8 ++ .../templates/security/registrations/new.html | 7 ++ flask_security/utils.py | 34 +++++++- flask_security/views.py | 56 ++++++++++--- tests/functional_tests.py | 82 ++++++++++++++---- 26 files changed, 317 insertions(+), 107 deletions(-) delete mode 100644 flask_security/templates/confirmed.html delete mode 100644 flask_security/templates/register.html delete mode 100644 flask_security/templates/reset.html create mode 100644 flask_security/templates/security/confirmations/new.html rename flask_security/templates/{ => security}/email/confirmation_instructions.html (100%) rename flask_security/templates/{ => security}/email/confirmation_instructions.txt (100%) create mode 100644 flask_security/templates/security/email/reset_instructions.html create mode 100644 flask_security/templates/security/email/reset_instructions.txt create mode 100644 flask_security/templates/security/email/reset_notice.html create mode 100644 flask_security/templates/security/email/reset_notice.txt rename flask_security/templates/{login.html => security/logins/new.html} (100%) create mode 100644 flask_security/templates/security/passwords/edit.html create mode 100644 flask_security/templates/security/passwords/new.html create mode 100644 flask_security/templates/security/registrations/edit.html create mode 100644 flask_security/templates/security/registrations/new.html diff --git a/example/app.py b/example/app.py index 728dff30..16cbbf7b 100644 --- a/example/app.py +++ b/example/app.py @@ -114,6 +114,8 @@ class User(db.Model, UserMixin): confirmation_token = db.Column(db.String(255)) confirmation_sent_at = db.Column(db.DateTime()) confirmed_at = db.Column(db.DateTime()) + reset_password_token = db.Column(db.String(255)) + reset_password_sent_at = db.Column(db.DateTime()) roles = db.relationship('Role', secondary=roles_users, backref=db.backref('users', lazy='dynamic')) @@ -147,6 +149,8 @@ class User(db.Document, UserMixin): confirmation_token = db.StringField(max_length=255) confirmation_sent_at = db.DateTimeField() confirmed_at = db.DateTimeField() + reset_password_token = db.StringField(max_length=255) + reset_password_sent_at = db.DateTimeField() roles = db.ListField(db.ReferenceField(Role), default=[]) Security(app, MongoEngineUserDatastore(db, User, Role)) diff --git a/example/templates/register.html b/example/templates/register.html index 3bcde60c..debcaffa 100644 --- a/example/templates/register.html +++ b/example/templates/register.html @@ -1,10 +1,10 @@ {% include "_messages.html" %} {% include "_nav.html" %}
    - {{ form.hidden_tag() }} - {{ form.email.label }} {{ form.email }}
    - {{ form.password.label }} {{ form.password }}
    - {{ form.password_confirm.label }} {{ form.password_confirm }}
    - {{ form.submit }} + {{ register_user_form.hidden_tag() }} + {{ register_user_form.email.label }} {{ register_user_form.email }}
    + {{ register_user_form.password.label }} {{ register_user_form.password }}
    + {{ register_user_form.password_confirm.label }} {{ register_user_form.password_confirm }}
    + {{ register_user_form.submit }}

    {{ content }}

    diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index bf40ecf9..9c1a849e 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -3,17 +3,18 @@ from flask import current_app, request, url_for from flask.ext.security.exceptions import UserNotFoundError, \ - ConfirmationError, ConfirmationExpiredError + ConfirmationError, TokenExpiredError from flask.ext.security.utils import generate_token, send_mail from werkzeug.local import LocalProxy security = LocalProxy(lambda: current_app.security) + logger = LocalProxy(lambda: current_app.logger) def find_user_by_confirmation_token(token): if not token: - raise ConfirmationError('Unknown confirmation token') + raise ConfirmationError('Confirmation token required') return security.datastore.find_user(confirmation_token=token) @@ -70,14 +71,18 @@ def confirmation_token_is_expired(user): def confirm_by_token(token): - user = find_user_by_confirmation_token(token) + try: + user = find_user_by_confirmation_token(token) + except UserNotFoundError: + raise ConfirmationError('Invalid confirmation token') if confirmation_token_is_expired(user): - raise ConfirmationExpiredError(user=user) + raise TokenExpiredError(message='Confirmation token is expired', + user=user) + user.confirmation_token = None + user.confirmation_sent_at = None user.confirmed_at = datetime.utcnow() - #user.confirmation_token = None - #user.confirmation_sent_at = None security.datastore._save_model(user) diff --git a/flask_security/core.py b/flask_security/core.py index 79eec4e9..73a20e89 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -33,21 +33,25 @@ 'AUTH_PROVIDER': 'flask.ext.security::AuthenticationProvider', 'LOGIN_FORM': 'flask.ext.security::LoginForm', 'REGISTER_FORM': 'flask.ext.security::RegisterForm', + 'RESET_PASSWORD_FORM': 'flask.ext.security::ResetPasswordForm', + 'FORGOT_PASSWORD_FORM': 'flask.ext.security::ForgotPasswordForm', 'AUTH_URL': '/auth', 'LOGOUT_URL': '/logout', 'REGISTER_URL': '/register', + 'FORGOT_URL': '/forgot', 'RESET_URL': '/reset', 'CONFIRM_URL': '/confirm', 'LOGIN_VIEW': '/login', 'POST_LOGIN_VIEW': '/', 'POST_LOGOUT_VIEW': '/', + 'POST_FORGOT_VIEW': '/', 'POST_REGISTER_VIEW': None, 'POST_CONFIRM_VIEW': None, - 'RESET_PASSWORD_WITHIN': 10, 'DEFAULT_ROLES': [], - 'LOGIN_WITHOUT_CONFIRMATION': False, 'CONFIRM_EMAIL': False, 'CONFIRM_EMAIL_WITHIN': '5 days', + 'RESET_PASSWORD_WITHIN': '2 days', + 'LOGIN_WITHOUT_CONFIRMATION': False, 'EMAIL_SENDER': 'no-reply@localhost' } @@ -198,7 +202,7 @@ def __init__(self, app=None, datastore=None, **kwargs): self.init_app(app, datastore, **kwargs) def init_app(self, app, datastore, - registerable=True, recoverable=False, template_folder=None): + registerable=True, recoverable=True, template_folder=None): """Initializes the Flask-Security extension for the specified application and datastore implentation. @@ -227,25 +231,32 @@ def init_app(self, app, datastore, self.datastore = datastore self.LoginForm = utils.get_class_from_string(app, 'LOGIN_FORM') self.RegisterForm = utils.get_class_from_string(app, 'REGISTER_FORM') + self.ResetPasswordForm = utils.get_class_from_string(app, 'RESET_PASSWORD_FORM') + self.ForgotPasswordForm = utils.get_class_from_string(app, 'FORGOT_PASSWORD_FORM') self.auth_url = utils.config_value(app, 'AUTH_URL') self.logout_url = utils.config_value(app, 'LOGOUT_URL') self.reset_url = utils.config_value(app, 'RESET_URL') self.register_url = utils.config_value(app, 'REGISTER_URL') self.confirm_url = utils.config_value(app, 'CONFIRM_URL') + self.forgot_url = utils.config_value(app, 'FORGOT_URL') self.post_login_view = utils.config_value(app, 'POST_LOGIN_VIEW') self.post_logout_view = utils.config_value(app, 'POST_LOGOUT_VIEW') self.post_register_view = utils.config_value(app, 'POST_REGISTER_VIEW') self.post_confirm_view = utils.config_value(app, 'POST_CONFIRM_VIEW') - self.reset_password_within = utils.config_value(app, 'RESET_PASSWORD_WITHIN') + self.post_forgot_view = utils.config_value(app, 'POST_FORGOT_VIEW') self.default_roles = utils.config_value(app, "DEFAULT_ROLES") self.login_without_confirmation = utils.config_value(app, 'LOGIN_WITHOUT_CONFIRMATION') self.confirm_email = utils.config_value(app, 'CONFIRM_EMAIL') self.email_sender = utils.config_value(app, 'EMAIL_SENDER') - self.confirm_email_within_text = utils.config_value(app, 'CONFIRM_EMAIL_WITHIN') + self.confirm_email_within_text = utils.config_value(app, 'CONFIRM_EMAIL_WITHIN') values = self.confirm_email_within_text.split() self.confirm_email_within = timedelta(**{values[1]: int(values[0])}) + self.reset_password_within_text = utils.config_value(app, 'RESET_PASSWORD_WITHIN') + values = self.reset_password_within_text.split() + self.reset_password_within = timedelta(**{values[1]: int(values[0])}) + identity_loaded.connect_via(app)(on_identity_loaded) bp = Blueprint('flask_security', __name__, template_folder='templates') @@ -257,29 +268,37 @@ def init_app(self, app, datastore, bp.route(self.logout_url, endpoint='logout')(login_required(views.logout)) - self.setup_register(bp) if registerable else None - self.setup_reset(bp) if recoverable else None - self.setup_confirm(bp) if self.confirm_email else None + self.setup_registerable(bp) if registerable else None + self.setup_recoverable(bp) if recoverable else None + self.setup_confirmable(bp) if self.confirm_email else None app.register_blueprint(bp, url_prefix=utils.config_value(app, 'URL_PREFIX')) app.security = self - def setup_register(self, bp): + def setup_registerable(self, bp): bp.route(self.register_url, methods=['POST'], endpoint='register')(views.register) - def setup_reset(self, bp): + def setup_recoverable(self, bp): + bp.route(self.forgot_url, + methods=['POST'], + endpoint='forgot')(views.forgot) bp.route(self.reset_url, methods=['POST'], endpoint='reset')(views.reset) - def setup_confirm(self, bp): + def setup_confirmable(self, bp): bp.route(self.confirm_url, endpoint='confirm')(views.confirm) +class ForgotPasswordForm(Form): + email = TextField("Email Address", + validators=[Required(message="Email not provided")]) + + class LoginForm(Form): """The default login form""" @@ -311,7 +330,8 @@ def to_dict(self): class ResetPasswordForm(Form): - token = HiddenField() + reset_password_token = HiddenField(validators=[Required()]) + email = HiddenField(validators=[Required()]) password = PasswordField("Password", validators=[Required(message="Password not provided")]) password_confirm = PasswordField("Retype Password", diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 4e5ef969..c47a59d8 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -99,7 +99,7 @@ def find_user(self, **kwargs): user = self._do_find_user(**kwargs) if user: return user - raise exceptions.UserNotFoundError() + raise exceptions.UserNotFoundError('Parameters=%s' % kwargs) def find_role(self, role): """Returns a role based on its name. diff --git a/flask_security/exceptions.py b/flask_security/exceptions.py index 3b00fac5..a060d8e3 100644 --- a/flask_security/exceptions.py +++ b/flask_security/exceptions.py @@ -10,68 +10,73 @@ """ -class BadCredentialsError(Exception): +class SecurityError(Exception): + def __init__(self, message=None, user=None): + super(SecurityError, self).__init__(message) + self.user = user + + +class BadCredentialsError(SecurityError): """Raised when an authentication attempt fails due to an error with the provided credentials. """ -class AuthenticationError(Exception): +class AuthenticationError(SecurityError): """Raised when an authentication attempt fails due to invalid configuration or an unknown reason. """ -class UserNotFoundError(Exception): +class UserNotFoundError(SecurityError): """Raised by a user datastore when there is an attempt to find a user by their identifier, often username or email, and the user is not found. """ -class RoleNotFoundError(Exception): +class RoleNotFoundError(SecurityError): """Raised by a user datastore when there is an attempt to find a role and the role cannot be found. """ -class UserIdNotFoundError(Exception): +class UserIdNotFoundError(SecurityError): """Raised by a user datastore when there is an attempt to find a user by ID and the user is not found. """ -class UserDatastoreError(Exception): +class UserDatastoreError(SecurityError): """Raised when a user datastore experiences an unexpected error """ -class UserCreationError(Exception): +class UserCreationError(SecurityError): """Raised when an error occurs when creating a user """ -class RoleCreationError(Exception): +class RoleCreationError(SecurityError): """Raised when an error occurs when creating a role """ -class ConfirmationError(Exception): - """Raised when an unknown confirmation error occurs +class ConfirmationError(SecurityError): + """Raised when an confirmation error occurs """ -class ConfirmationExpiredError(Exception): +class TokenExpiredError(SecurityError): """Raised when a user attempts to confirm their email but their token has expired """ - def __init__(self, user=None): - super(ConfirmationExpiredError, self).__init__() - self.user = user -class ConfirmationRequiredError(Exception): +class ConfirmationRequiredError(SecurityError): """Raised when a user attempts to login but requires confirmation """ - def __init__(self, user=None): - super(ConfirmationRequiredError, self).__init__() - self.user = user + + +class ResetPasswordError(SecurityError): + """Raised when a password reset error occurs + """ diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index cee2a0fe..addb248c 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -1,34 +1,82 @@ -from datetime import datetime, timedelta +from datetime import datetime -from flask import current_app -from flask.ext.security.utils import generate_token +from flask import current_app, request, url_for +from flask.ext.security.exceptions import ResetPasswordError, \ + UserNotFoundError, TokenExpiredError +from flask.ext.security.signals import password_reset_requested +from flask.ext.security.utils import generate_token, send_mail +from werkzeug.local import LocalProxy +security = LocalProxy(lambda: current_app.security) -def reset_password_period_valid(user): - sent_at = user.reset_password_sent_at - reset_within = int(current_app.security.reset_password_within) - days_ago = datetime.utcnow() - timedelta(days=reset_within) +logger = LocalProxy(lambda: current_app.logger) - return (sent_at is not None) and \ - (sent_at >= days_ago) + +def find_user_by_reset_token(token): + if not token: + raise ResetPasswordError('Reset password token required') + return security.datastore.find_user(reset_password_token=token) + + +def send_reset_password_instructions(user): + url = url_for('flask_security.reset', + reset_token=user.reset_password_token) + + reset_link = request.url_root[:-1] + url + + send_mail('Password reset instructions', + user.email, + 'reset_instructions', + dict(user=user, reset_link=reset_link)) + + return True def generate_reset_password_token(user): - user.reset_password_token = generate_token() - user.reset_password_sent_at = datetime.utcnow() - current_app.security.datastore._save_model(user) + while True: + token = generate_token() + try: + find_user_by_reset_token(token) + except UserNotFoundError: + break + + now = datetime.utcnow() + + try: + user['reset_password_token'] = token + user['reset_password_token'] = now + except TypeError: + user.reset_password_token = token + user.reset_password_sent_at = now + + return user + + +def password_reset_token_is_expired(user): + token_expires = datetime.utcnow() - security.reset_password_within + return user.reset_password_sent_at < token_expires + + +def reset_by_token(token, email, password): + try: + user = find_user_by_reset_token(token) + except UserNotFoundError: + raise ResetPasswordError('Invalid reset password token') + if password_reset_token_is_expired(user): + raise TokenExpiredError('Reset password token is expired', user) -def clear_reset_password_token(user): user.reset_password_token = None user.reset_password_sent_at = None + user.password = security.pwd_context.encrypt(password) + security.datastore._save_model(user) -def send_reset_password_instructions(): - pass + return user -def should_generate_reset_token(user): - return (user.reset_password_token is None) or \ - (not reset_password_period_valid(user)) +def reset_password_reset_token(user): + security.datastore._save_model(generate_reset_password_token(user)) + send_reset_password_instructions(user) + password_reset_requested.send(user, app=current_app._get_current_object()) diff --git a/flask_security/signals.py b/flask_security/signals.py index 6c257ad3..39901264 100644 --- a/flask_security/signals.py +++ b/flask_security/signals.py @@ -2,4 +2,6 @@ signals = blinker.Namespace() -user_registered = signals.signal("user-register") +user_registered = signals.signal("user-registered") + +password_reset_requested = signals.signal("password-reset-requested") diff --git a/flask_security/templates/confirmed.html b/flask_security/templates/confirmed.html deleted file mode 100644 index e69de29b..00000000 diff --git a/flask_security/templates/register.html b/flask_security/templates/register.html deleted file mode 100644 index c875955c..00000000 --- a/flask_security/templates/register.html +++ /dev/null @@ -1,7 +0,0 @@ -
    - {{ form.hidden_tag() }} - {{ form.email.label }} {{ form.email }}
    - {{ form.password.label }} {{ form.password }}
    - {{ form.password_confirm.label }} {{ form.password_confirm }}
    - {{ form.submit }} -
    \ No newline at end of file diff --git a/flask_security/templates/reset.html b/flask_security/templates/reset.html deleted file mode 100644 index 744448bf..00000000 --- a/flask_security/templates/reset.html +++ /dev/null @@ -1,6 +0,0 @@ -
    - {{ form.hidden_tag() }} - {{ form.password.label }} {{ form.password }}
    - {{ form.password_confirm.label }} {{ form.password_confirm }}
    - {{ form.submit }} -
    \ No newline at end of file diff --git a/flask_security/templates/security/confirmations/new.html b/flask_security/templates/security/confirmations/new.html new file mode 100644 index 00000000..36a1ca07 --- /dev/null +++ b/flask_security/templates/security/confirmations/new.html @@ -0,0 +1 @@ +Resend confirmation instructions... \ No newline at end of file diff --git a/flask_security/templates/email/confirmation_instructions.html b/flask_security/templates/security/email/confirmation_instructions.html similarity index 100% rename from flask_security/templates/email/confirmation_instructions.html rename to flask_security/templates/security/email/confirmation_instructions.html diff --git a/flask_security/templates/email/confirmation_instructions.txt b/flask_security/templates/security/email/confirmation_instructions.txt similarity index 100% rename from flask_security/templates/email/confirmation_instructions.txt rename to flask_security/templates/security/email/confirmation_instructions.txt diff --git a/flask_security/templates/security/email/reset_instructions.html b/flask_security/templates/security/email/reset_instructions.html new file mode 100644 index 00000000..fd0b48d8 --- /dev/null +++ b/flask_security/templates/security/email/reset_instructions.html @@ -0,0 +1 @@ +

    Click here to reset your password

    \ No newline at end of file diff --git a/flask_security/templates/security/email/reset_instructions.txt b/flask_security/templates/security/email/reset_instructions.txt new file mode 100644 index 00000000..91ac288e --- /dev/null +++ b/flask_security/templates/security/email/reset_instructions.txt @@ -0,0 +1,3 @@ +Click the link below to reset your password: + +{{ reset_link }} \ No newline at end of file diff --git a/flask_security/templates/security/email/reset_notice.html b/flask_security/templates/security/email/reset_notice.html new file mode 100644 index 00000000..536e2961 --- /dev/null +++ b/flask_security/templates/security/email/reset_notice.html @@ -0,0 +1 @@ +

    Your password has been reset

    \ No newline at end of file diff --git a/flask_security/templates/security/email/reset_notice.txt b/flask_security/templates/security/email/reset_notice.txt new file mode 100644 index 00000000..a3fa0b4b --- /dev/null +++ b/flask_security/templates/security/email/reset_notice.txt @@ -0,0 +1 @@ +Your password has been reset \ No newline at end of file diff --git a/flask_security/templates/login.html b/flask_security/templates/security/logins/new.html similarity index 100% rename from flask_security/templates/login.html rename to flask_security/templates/security/logins/new.html diff --git a/flask_security/templates/security/passwords/edit.html b/flask_security/templates/security/passwords/edit.html new file mode 100644 index 00000000..0c3ea67d --- /dev/null +++ b/flask_security/templates/security/passwords/edit.html @@ -0,0 +1,6 @@ +
    + {{ reset_password_form.hidden_tag() }} + {{ reset_password_form.password.label }} {{ reset_password_form.password }}
    + {{ reset_password_form.password_confirm.label }} {{ reset_password_form.password_confirm }}
    + {{ reset_password_form.submit }} +
    \ No newline at end of file diff --git a/flask_security/templates/security/passwords/new.html b/flask_security/templates/security/passwords/new.html new file mode 100644 index 00000000..c8f6a17d --- /dev/null +++ b/flask_security/templates/security/passwords/new.html @@ -0,0 +1,5 @@ +
    + {{ forgot_password_form.hidden_tag() }} + {{ forgot_password_form.email.label }} {{ forgot_password_form.email }} + {{ forgot_password_form.submit }} +
    \ No newline at end of file diff --git a/flask_security/templates/security/registrations/edit.html b/flask_security/templates/security/registrations/edit.html new file mode 100644 index 00000000..a37845ea --- /dev/null +++ b/flask_security/templates/security/registrations/edit.html @@ -0,0 +1,8 @@ +
    + {{ edit_user_form.hidden_tag() }} + {{ edit_user_form.email.label }} {{ edit_user_form.email }}
    + {{ edit_user_form.password.label }} {{ edit_user_form.password }}
    + {{ edit_user_form.password_confirm.label }} {{ edit_user_form.password_confirm }}
    + {{ edit_user_form.current_password.label }} {{ edit_user_form.current_password }}
    + {{ edit_user_form.submit }} +
    \ No newline at end of file diff --git a/flask_security/templates/security/registrations/new.html b/flask_security/templates/security/registrations/new.html new file mode 100644 index 00000000..c9d7bf7a --- /dev/null +++ b/flask_security/templates/security/registrations/new.html @@ -0,0 +1,7 @@ +
    + {{ register_user_form.hidden_tag() }} + {{ register_user_form.email.label }} {{ register_user_form.email }}
    + {{ register_user_form.password.label }} {{ register_user_form.password }}
    + {{ register_user_form.password_confirm.label }} {{ register_user_form.password_confirm }}
    + {{ register_user_form.submit }} +
    \ No newline at end of file diff --git a/flask_security/utils.py b/flask_security/utils.py index 071f5abe..e467c5df 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -16,7 +16,8 @@ from importlib import import_module from flask import url_for, flash, current_app, request, session, render_template -from flask.ext.security.signals import user_registered +from flask.ext.security.signals import user_registered, password_reset_requested +from werkzeug.exceptions import BadRequest def generate_token(): @@ -74,8 +75,9 @@ def send_mail(subject, recipient, template, context): sender=current_app.security.email_sender, recipients=[recipient]) - msg.body = render_template('email/%s.txt' % template, **context) - msg.html = render_template('email/%s.html' % template, **context) + base = 'security/email' + msg.body = render_template('%s/%s.txt' % (base, template), **context) + msg.html = render_template('%s/%s.html' % (base, template), **context) current_app.mail.send(msg) @@ -97,3 +99,29 @@ def _on(user, app): yield users finally: user_registered.disconnect(_on) + + +@contextmanager +def capture_reset_password_requests(reset_password_sent_at=None): + users = [] + + def _on(user, app): + if reset_password_sent_at: + user.reset_password_sent_at = reset_password_sent_at + current_app.security.datastore._save_model(user) + + users.append(user) + + password_reset_requested.connect(_on) + + try: + yield users + finally: + password_reset_requested.disconnect(_on) + + +def get_arg_or_bad_request(context, name): + rv = context.get(name, None) + if not rv: + raise BadRequest() + return rv diff --git a/flask_security/views.py b/flask_security/views.py index 8c9440c9..d3fed60e 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -9,14 +9,16 @@ :license: MIT, see LICENSE for more details. """ -from flask import current_app, redirect, request, session +from flask import current_app, redirect, request, session, render_template from flask.ext.login import login_user, logout_user from flask.ext.principal import Identity, AnonymousIdentity, identity_changed from flask.ext.security.confirmable import confirm_by_token, \ confirmation_token_is_expired, requires_confirmation, \ reset_confirmation_token, send_confirmation_instructions -from flask.ext.security.exceptions import ConfirmationExpiredError, \ - ConfirmationError, BadCredentialsError +from flask.ext.security.recoverable import reset_by_token, \ + reset_password_reset_token +from flask.ext.security.exceptions import TokenExpiredError, UserNotFoundError, \ + ConfirmationError, BadCredentialsError, ResetPasswordError from flask.ext.security.utils import get_post_login_redirect, do_flash from flask.ext.security.signals import user_registered from werkzeug.local import LocalProxy @@ -70,7 +72,7 @@ def authenticate(): do_flash(msg, 'error') - logger.debug('Unsuccessful authentication attempt: %s. ' % msg) + logger.debug('Unsuccessful authentication attempt: %s' % msg) return redirect(request.referrer or security.login_manager.login_view) @@ -129,15 +131,16 @@ def confirm(): """View function which confirms a user's email address using a token taken from the value of the `confirmation_token` query string argument. """ + try: token = request.args.get('confirmation_token', None) - confirm_by_token(token) + user = confirm_by_token(token) except ConfirmationError, e: do_flash(str(e), 'error') return redirect('/') # TODO: Don't just redirect to root - except ConfirmationExpiredError, e: + except TokenExpiredError, e: reset_confirmation_token(e.user) msg = 'You did not confirm your email within %s. ' \ @@ -146,17 +149,44 @@ def confirm(): do_flash(msg, 'error') - return redirect('/') + return redirect('/') # TODO: Don't redirect to root + logger.debug('User %s confirmed' % user) do_flash('Your email has been confirmed. You may now log in.', 'success') return redirect(security.post_confirm_view or security.post_login_view) +def forgot(): + form = security.ForgotPasswordForm(csrf_enabled=not current_app.testing) + + if form.validate_on_submit(): + try: + user = security.datastore.find_user(email=form.email.data) + reset_password_reset_token(user) + do_flash('Instructions to reset your password have been sent to %s' % user.email, 'success') + + except UserNotFoundError: + do_flash('The email you provided could not be found', 'error') + + return redirect(security.post_forgot_view) + + return render_template('security/passwords/new.html', forgot_password_form=form) + + def reset(): - # user = something - # if reset_password_period_valid_for_user(user): - # user.reset_password_sent_at = datetime.utcnow() - # user.reset_password_token = token - # current_app.security.datastore._save_model(user) - pass + form = security.ResetPasswordForm(csrf_enabled=not current_app.testing) + + if form.validate_on_submit(): + try: + reset_by_token(token=form.reset_password_token.data, + email=form.email.data, + password=form.password.data) + + except ResetPasswordError, e: + do_flash(str(e)) + + except TokenExpiredError, e: + do_flash('You did not reset your password within %s.' % security.reset_password_within_text) + + return redirect(request.referrer) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 75754682..3dca3d98 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -3,7 +3,8 @@ import unittest from datetime import datetime, timedelta -from flask.ext.security.utils import capture_registrations +from flask.ext.security.utils import capture_registrations, \ + capture_reset_password_requests from example import app @@ -33,8 +34,9 @@ def _post(self, route, data=None, content_type=None, follow_redirects=True): follow_redirects=follow_redirects, content_type=content_type or 'application/x-www-form-urlencoded') - def register(self, email, password, endpoint=None): - return self._post(endpoint or '/register') + def register(self, email, password='password'): + data = dict(email=email, password=password, password_confirm=password) + return self.client.post('/register', data=data, follow_redirects=True) def authenticate(self, email, password, endpoint=None): data = dict(email=email, password=password) @@ -113,12 +115,6 @@ def test_unauthenticated_role_required(self): r = self._get('/admin', follow_redirects=True) self.assertIn(' Date: Tue, 22 May 2012 18:13:29 -0400 Subject: [PATCH 020/234] Added reset password error handling and associated test --- flask_security/core.py | 2 ++ flask_security/views.py | 4 ++-- tests/functional_tests.py | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 73a20e89..a086b37f 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -45,6 +45,7 @@ 'POST_LOGIN_VIEW': '/', 'POST_LOGOUT_VIEW': '/', 'POST_FORGOT_VIEW': '/', + 'RESET_PASSWORD_ERROR_VIEW': '/', 'POST_REGISTER_VIEW': None, 'POST_CONFIRM_VIEW': None, 'DEFAULT_ROLES': [], @@ -244,6 +245,7 @@ def init_app(self, app, datastore, self.post_register_view = utils.config_value(app, 'POST_REGISTER_VIEW') self.post_confirm_view = utils.config_value(app, 'POST_CONFIRM_VIEW') self.post_forgot_view = utils.config_value(app, 'POST_FORGOT_VIEW') + self.reset_password_error_view = utils.config_value(app, 'RESET_PASSWORD_ERROR_VIEW') self.default_roles = utils.config_value(app, "DEFAULT_ROLES") self.login_without_confirmation = utils.config_value(app, 'LOGIN_WITHOUT_CONFIRMATION') self.confirm_email = utils.config_value(app, 'CONFIRM_EMAIL') diff --git a/flask_security/views.py b/flask_security/views.py index d3fed60e..09614f12 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -184,9 +184,9 @@ def reset(): password=form.password.data) except ResetPasswordError, e: - do_flash(str(e)) + do_flash(str(e), 'error') except TokenExpiredError, e: do_flash('You did not reset your password within %s.' % security.reset_password_within_text) - return redirect(request.referrer) + return redirect(request.referrer or security.reset_password_error_view) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 3dca3d98..059d8421 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -263,6 +263,27 @@ def test_reset_password_with_valid_token(self): r = self.authenticate('joe@lp.com', 'newpassword') self.assertIn('Hello joe@lp.com', r.data) + def test_reset_password_twice_flashes_invalid_token_msg(self): + u = None + with capture_reset_password_requests() as users: + r = self.client.post('/forgot', data=dict(email='joe@lp.com')) + u = users[0] + + self.client.post('/reset', data={ + 'email': u.email, + 'reset_password_token': u.reset_password_token, + 'password': 'newpassword', + 'password_confirm': 'newpassword' + }) + + r = self.client.post('/reset', data={ + 'email': u.email, + 'reset_password_token': u.reset_password_token, + 'password': 'newpassword', + 'password_confirm': 'newpassword' + }, follow_redirects=True) + self.assertIn('Invalid reset password token', r.data) + class MongoEngineSecurityTests(DefaultSecurityTests): From 20abde5542de038f9253b4036cc4533314cdc4bc Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 22 May 2012 18:34:42 -0400 Subject: [PATCH 021/234] Add Flask-Mail dependency --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 93952f83..91477e5a 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,7 @@ 'Flask-Login', 'Flask-Principal', 'Flask-WTF', + 'Flask-Mail', 'passlib' ], test_suite='nose.collector', From 08582b7f82c33134ca45e307ca5deb9a096004d4 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 22 May 2012 18:42:31 -0400 Subject: [PATCH 022/234] Remove old Flask-MongoEngine dependency since it is now officially on pypi --- setup.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/setup.py b/setup.py index 91477e5a..15476dee 100644 --- a/setup.py +++ b/setup.py @@ -45,9 +45,6 @@ 'Flask-MongoEngine', 'py-bcrypt' ], - dependency_links=[ - 'http://github.com/sbook/flask-mongoengine/tarball/master#egg=Flask-MongoEngine-0.1.3-dev' - ], classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: Web Environment', From a47c6b9d55dc518a22c23350b0f530aab6cce2bb Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 24 May 2012 21:07:29 -0400 Subject: [PATCH 023/234] Polish --- flask_security/core.py | 3 ++- flask_security/recoverable.py | 2 ++ flask_security/utils.py | 18 +++++------------- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index a086b37f..509d2b65 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -293,7 +293,8 @@ def setup_recoverable(self, bp): endpoint='reset')(views.reset) def setup_confirmable(self, bp): - bp.route(self.confirm_url, endpoint='confirm')(views.confirm) + bp.route(self.confirm_url, + endpoint='confirm')(views.confirm) class ForgotPasswordForm(Form): diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index addb248c..86cc4249 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -73,6 +73,8 @@ def reset_by_token(token, email, password): security.datastore._save_model(user) + send_mail('Your password has been reset', user.email, 'reset_notice') + return user diff --git a/flask_security/utils.py b/flask_security/utils.py index e467c5df..da80ad82 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -17,7 +17,6 @@ from flask import url_for, flash, current_app, request, session, render_template from flask.ext.security.signals import user_registered, password_reset_requested -from werkzeug.exceptions import BadRequest def generate_token(): @@ -57,10 +56,8 @@ def find_redirect(key): result = (get_url(session.pop(key.lower(), None)) or get_url(current_app.config[key.upper()] or None) or '/') - try: - del session[key.lower()] - except: - pass + session.pop(key.lower(), None) + return result @@ -68,9 +65,11 @@ def config_value(app, key, default=None): return app.config.get('SECURITY_' + key.upper(), default) -def send_mail(subject, recipient, template, context): +def send_mail(subject, recipient, template, context=None): from flask.ext.mail import Message + context = context or {} + msg = Message(subject, sender=current_app.security.email_sender, recipients=[recipient]) @@ -118,10 +117,3 @@ def _on(user, app): yield users finally: password_reset_requested.disconnect(_on) - - -def get_arg_or_bad_request(context, name): - rv = context.get(name, None) - if not rv: - raise BadRequest() - return rv From 112c419403624b521705ac84f29731c5b19e312e Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 24 May 2012 21:15:31 -0400 Subject: [PATCH 024/234] A bit more polish --- flask_security/core.py | 8 ++++++++ flask_security/views.py | 8 -------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 509d2b65..70080799 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -21,6 +21,8 @@ from flask.ext.wtf import Form, TextField, PasswordField, SubmitField, \ HiddenField, Required, BooleanField, EqualTo, Email from flask.ext.security import views, exceptions, utils +from flask.ext.security.confirmable import confirmation_token_is_expired, \ + requires_confirmation, reset_confirmation_token from passlib.context import CryptContext from werkzeug.datastructures import ImmutableList @@ -387,6 +389,12 @@ def do_authenticate(self, email, password): except Exception, e: self.auth_error('Unexpected authentication error: %s' % e) + if confirmation_token_is_expired(user): + reset_confirmation_token(user) + + if requires_confirmation(user): + raise exceptions.BadCredentialsError('Account requires confirmation') + # compare passwords if current_app.security.pwd_context.verify(password, user.password): return user diff --git a/flask_security/views.py b/flask_security/views.py index 09614f12..359ec971 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -13,7 +13,6 @@ from flask.ext.login import login_user, logout_user from flask.ext.principal import Identity, AnonymousIdentity, identity_changed from flask.ext.security.confirmable import confirm_by_token, \ - confirmation_token_is_expired, requires_confirmation, \ reset_confirmation_token, send_confirmation_instructions from flask.ext.security.recoverable import reset_by_token, \ reset_password_reset_token @@ -52,13 +51,6 @@ def authenticate(): try: user = security.auth_provider.authenticate(form) - # Conveniently reset the token if necessary and expired - if confirmation_token_is_expired(user): - reset_confirmation_token(user) - - if requires_confirmation(user): - raise BadCredentialsError('Account requires confirmation') - if _do_login(user, remember=form.remember.data): return redirect(get_post_login_redirect()) From 97d49395baada96abad44bd3de132617cadfc51b Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 24 May 2012 21:40:44 -0400 Subject: [PATCH 025/234] More cleanup --- tests/unit_tests.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/tests/unit_tests.py b/tests/unit_tests.py index ece4c8e3..9a3158e9 100644 --- a/tests/unit_tests.py +++ b/tests/unit_tests.py @@ -1,7 +1,9 @@ + import unittest -import flask_security + from flask_security import RoleMixin, UserMixin, AnonymousUser + class Role(RoleMixin): def __init__(self, name, description=None): self.name = name @@ -13,32 +15,29 @@ def __init__(self, username, email, roles): self.username = username self.email = email self.roles = roles - -# set the models or we'll get errors -flask_security.User = User -flask_security.Role = Role - + admin = Role('admin') admin2 = Role('admin') editor = Role('editor') user = User('matt', 'matt@lp.com', [admin, editor]) + class SecurityEntityTests(unittest.TestCase): - - def test_role_mixin_equal(self): + + def test_role_mixin_equal(self): self.assertEqual(admin, admin2) - - def test_role_mixin_not_equal(self): + + def test_role_mixin_not_equal(self): self.assertNotEqual(admin, editor) - + def test_user_mixin_has_role_with_string(self): self.assertTrue(user.has_role('admin')) - + def test_user_mixin_has_role_with_role_obj(self): self.assertTrue(user.has_role(Role('admin'))) - + def test_anonymous_user_has_no_roles(self): au = AnonymousUser() self.assertEqual(0, len(au.roles)) - self.assertFalse(au.has_role('admin')) \ No newline at end of file + self.assertFalse(au.has_role('admin')) From 2d0c49f4b4703e61b19ea58a8f7cbdf961cc82ab Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 24 May 2012 21:44:50 -0400 Subject: [PATCH 026/234] More cleanup --- flask_security/core.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 70080799..f741ac19 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -204,8 +204,7 @@ class Security(object): def __init__(self, app=None, datastore=None, **kwargs): self.init_app(app, datastore, **kwargs) - def init_app(self, app, datastore, - registerable=True, recoverable=True, template_folder=None): + def init_app(self, app, datastore, registerable=True, recoverable=True): """Initializes the Flask-Security extension for the specified application and datastore implentation. From d3202c03ba2458737e7e7031ed909d54cd46843b Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 6 Jun 2012 17:46:06 -0400 Subject: [PATCH 027/234] Fix #11 --- flask_security/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/views.py b/flask_security/views.py index 359ec971..5a02c839 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -60,7 +60,7 @@ def authenticate(): msg = str(e) except Exception, e: - msg = 'Uknown authentication error' + msg = 'Unknown authentication error' do_flash(msg, 'error') From e0c7a3deb20ce3033bd63d30ac742ffac3b415e3 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 6 Jun 2012 17:51:14 -0400 Subject: [PATCH 028/234] Fix #10 --- flask_security/confirmable.py | 7 +++++-- tests/functional_tests.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 9c1a849e..992c8dc3 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -76,12 +76,15 @@ def confirm_by_token(token): except UserNotFoundError: raise ConfirmationError('Invalid confirmation token') + if user.confirmed_at: + raise ConfirmationError('Account has already been confirmed') + if confirmation_token_is_expired(user): raise TokenExpiredError(message='Confirmation token is expired', user=user) - user.confirmation_token = None - user.confirmation_sent_at = None + #user.confirmation_token = None + #user.confirmation_sent_at = None user.confirmed_at = datetime.utcnow() security.datastore._save_model(user) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 059d8421..e80f6aec 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -186,7 +186,7 @@ def test_confirm_email_twice_flashes_invalid_token_msg(self): self.client.get('/confirm?confirmation_token=' + token, follow_redirects=True) r = self.client.get('/confirm?confirmation_token=' + token, follow_redirects=True) - self.assertIn('Invalid confirmation token', r.data) + self.assertIn('Account has already been confirmed', r.data) def test_unprovided_token_when_confirming_email(self): r = self.client.get('/confirm', follow_redirects=True) From 7263388b7c17a761e972960db11b087805b42583 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 7 Jun 2012 11:15:42 -0400 Subject: [PATCH 029/234] refactor tests a tiny bit --- tests/__init__.py | 42 ++++++++++++++++++ tests/functional_tests.py | 90 +++++++++++---------------------------- 2 files changed, 67 insertions(+), 65 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index e69de29b..e94077a6 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,42 @@ +from unittest import TestCase +from example import app + + +class SecurityTest(TestCase): + + AUTH_CONFIG = None + + def setUp(self): + super(SecurityTest, self).setUp() + + self.app = self._create_app(self.AUTH_CONFIG or None) + self.app.debug = False + self.app.config['TESTING'] = True + + self.client = self.app.test_client() + + def _create_app(self, auth_config): + return app.create_sqlalchemy_app(auth_config) + + def _get(self, route, content_type=None, follow_redirects=None): + return self.client.get(route, follow_redirects=follow_redirects, + content_type=content_type or 'text/html') + + def _post(self, route, data=None, content_type=None, follow_redirects=True): + return self.client.post(route, data=data, + follow_redirects=follow_redirects, + content_type=content_type or 'application/x-www-form-urlencoded') + + def register(self, email, password='password'): + data = dict(email=email, password=password, password_confirm=password) + return self.client.post('/register', data=data, follow_redirects=True) + + def authenticate(self, email="matt@lp.com", password="password", endpoint=None): + data = dict(email=email, password=password) + return self._post(endpoint or '/auth', data=data) + + def logout(self, endpoint=None): + return self._get(endpoint or '/logout', follow_redirects=True) + + def assertIsHomePage(self, data): + self.assertIn('Home Page', data) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index e80f6aec..177832c5 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -1,49 +1,12 @@ # -*- coding: utf-8 -*- -import unittest from datetime import datetime, timedelta from flask.ext.security.utils import capture_registrations, \ capture_reset_password_requests from example import app - - -class SecurityTest(unittest.TestCase): - - AUTH_CONFIG = None - - def setUp(self): - super(SecurityTest, self).setUp() - - self.app = self._create_app(self.AUTH_CONFIG or None) - self.app.debug = False - self.app.config['TESTING'] = True - - self.client = self.app.test_client() - - def _create_app(self, auth_config): - return app.create_sqlalchemy_app(auth_config) - - def _get(self, route, content_type=None, follow_redirects=None): - return self.client.get(route, follow_redirects=follow_redirects, - content_type=content_type or 'text/html') - - def _post(self, route, data=None, content_type=None, follow_redirects=True): - return self.client.post(route, data=data, - follow_redirects=follow_redirects, - content_type=content_type or 'application/x-www-form-urlencoded') - - def register(self, email, password='password'): - data = dict(email=email, password=password, password_confirm=password) - return self.client.post('/register', data=data, follow_redirects=True) - - def authenticate(self, email, password, endpoint=None): - data = dict(email=email, password=password) - return self._post(endpoint or '/auth', data=data) - - def logout(self, endpoint=None): - return self._get(endpoint or '/logout', follow_redirects=True) +from tests import SecurityTest class DefaultSecurityTests(SecurityTest): @@ -53,23 +16,23 @@ def test_login_view(self): self.assertIn('Login Page', r.data) def test_authenticate(self): - r = self.authenticate("matt@lp.com", "password") + r = self.authenticate() self.assertIn('Hello matt@lp.com', r.data) def test_unprovided_username(self): - r = self.authenticate("", "password") + r = self.authenticate("") self.assertIn("Email not provided", r.data) def test_unprovided_password(self): - r = self.authenticate("matt@lp.com", "") + r = self.authenticate(password="") self.assertIn("Password not provided", r.data) def test_invalid_user(self): - r = self.authenticate("bogus", "password") + r = self.authenticate(email="bogus") self.assertIn("Specified user does not exist", r.data) def test_bad_password(self): - r = self.authenticate("matt@lp.com", "bogus") + r = self.authenticate(password="bogus") self.assertIn("Password does not match", r.data) def test_inactive_user(self): @@ -77,39 +40,39 @@ def test_inactive_user(self): self.assertIn("Inactive user", r.data) def test_logout(self): - self.authenticate("matt@lp.com", "password") + self.authenticate() r = self.logout() - self.assertIn('Home Page', r.data) + self.assertIsHomePage(r.data) def test_unauthorized_access(self): r = self._get('/profile', follow_redirects=True) self.assertIn('Please log in to access this page', r.data) def test_authorized_access(self): - self.authenticate("matt@lp.com", "password") + self.authenticate() r = self._get("/profile") self.assertIn('profile', r.data) def test_valid_admin_role(self): - self.authenticate("matt@lp.com", "password") + self.authenticate() r = self._get("/admin") self.assertIn('Admin Page', r.data) def test_invalid_admin_role(self): - self.authenticate("joe@lp.com", "password") + self.authenticate("joe@lp.com") r = self._get("/admin", follow_redirects=True) - self.assertIn('Home Page', r.data) + self.assertIsHomePage(r.data) def test_roles_accepted(self): for user in ("matt@lp.com", "joe@lp.com"): - self.authenticate(user, "password") + self.authenticate(user) r = self._get("/admin_or_editor") self.assertIn('Admin or Editor Page', r.data) self.logout() - self.authenticate("jill@lp.com", "password") + self.authenticate("jill@lp.com") r = self._get("/admin_or_editor", follow_redirects=True) - self.assertIn('Home Page', r.data) + self.assertIsHomePage(r.data) def test_unauthenticated_role_required(self): r = self._get('/admin', follow_redirects=True) @@ -132,11 +95,11 @@ def test_login_view(self): self.assertIn("Custom Login Page", r.data) def test_authenticate(self): - r = self.authenticate("matt@lp.com", "password", endpoint="/custom_auth") + r = self.authenticate(endpoint="/custom_auth") self.assertIn('Post Login', r.data) def test_logout(self): - self.authenticate("matt@lp.com", "password", endpoint="/custom_auth") + self.authenticate(endpoint="/custom_auth") r = self.logout(endpoint="/custom_logout") self.assertIn('Post Logout', r.data) @@ -151,7 +114,7 @@ class RegisterableTests(SecurityTest): def test_register_valid_user(self): data = dict(email='dude@lp.com', password='password', password_confirm='password') self.client.post('/register', data=data, follow_redirects=True) - r = self.authenticate('dude@lp.com', 'password') + r = self.authenticate('dude@lp.com') self.assertIn('Hello dude@lp.com', r.data) @@ -184,8 +147,9 @@ def test_confirm_email_twice_flashes_invalid_token_msg(self): self.register(e) token = users[0].confirmation_token - self.client.get('/confirm?confirmation_token=' + token, follow_redirects=True) - r = self.client.get('/confirm?confirmation_token=' + token, follow_redirects=True) + url = '/confirm?confirmation_token=' + token + self.client.get(url, follow_redirects=True) + r = self.client.get(url, follow_redirects=True) self.assertIn('Account has already been confirmed', r.data) def test_unprovided_token_when_confirming_email(self): @@ -269,19 +233,15 @@ def test_reset_password_twice_flashes_invalid_token_msg(self): r = self.client.post('/forgot', data=dict(email='joe@lp.com')) u = users[0] - self.client.post('/reset', data={ + data = { 'email': u.email, 'reset_password_token': u.reset_password_token, 'password': 'newpassword', 'password_confirm': 'newpassword' - }) + } - r = self.client.post('/reset', data={ - 'email': u.email, - 'reset_password_token': u.reset_password_token, - 'password': 'newpassword', - 'password_confirm': 'newpassword' - }, follow_redirects=True) + self.client.post('/reset', data=data) + r = self.client.post('/reset', data=data, follow_redirects=True) self.assertIn('Invalid reset password token', r.data) From ca25c253d5940c57b7797aa154ae309b9a534ec2 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 8 Jun 2012 14:17:33 -0400 Subject: [PATCH 030/234] Start work on token authentication --- flask_security/tokens.py | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 flask_security/tokens.py diff --git a/flask_security/tokens.py b/flask_security/tokens.py new file mode 100644 index 00000000..73866629 --- /dev/null +++ b/flask_security/tokens.py @@ -0,0 +1,44 @@ + +from datetime import datetime + +from flask import current_app +from flask.ext.security.exceptions import BadCredentialsError, \ + UserNotFoundError +from flask.ext.security.utils import generate_token +from werkzeug.local import LocalProxy + +security = LocalProxy(lambda: current_app.security) + + +def find_user_by_authentication_token(token): + if not token: + raise BadCredentialsError('Authentication token required') + return security.datastore.find_user(authentication_token=token) + + +def generate_authentication_token(user): + while True: + token = generate_token() + try: + find_user_by_authentication_token(token) + except UserNotFoundError: + break + + now = datetime.utcnow() + + try: + user['authentication_token'] = token + user['authentication_token_generated_at'] = now + except TypeError: + user.authentication_token = token + user.authentication_token_generated_at = now + + return user + + +def login_by_token(token): + pass + + +def reset_authentication_token(user): + security.datastore._save_model(generate_authentication_token(user)) From c4ada0d99870458c70ac44c96fc13eb8be1faf2e Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 8 Jun 2012 14:28:38 -0400 Subject: [PATCH 031/234] add query string and header config --- flask_security/core.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flask_security/core.py b/flask_security/core.py index f741ac19..4c493561 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -55,7 +55,9 @@ 'CONFIRM_EMAIL_WITHIN': '5 days', 'RESET_PASSWORD_WITHIN': '2 days', 'LOGIN_WITHOUT_CONFIRMATION': False, - 'EMAIL_SENDER': 'no-reply@localhost' + 'EMAIL_SENDER': 'no-reply@localhost', + 'TOKEN_AUTHENTICATION_KEY': 'auth_token', + 'TOKEN_AUTHENTICATION_HEADER': 'X-Auth-Token' } @@ -251,6 +253,8 @@ def init_app(self, app, datastore, registerable=True, recoverable=True): self.login_without_confirmation = utils.config_value(app, 'LOGIN_WITHOUT_CONFIRMATION') self.confirm_email = utils.config_value(app, 'CONFIRM_EMAIL') self.email_sender = utils.config_value(app, 'EMAIL_SENDER') + self.token_authentication_key = utils.config_value(app, 'TOKEN_AUTHENTICATION_KEY') + self.token_authentication_header = utils.config_value(app, 'TOKEN_AUTHENTICATION_HEADER') self.confirm_email_within_text = utils.config_value(app, 'CONFIRM_EMAIL_WITHIN') values = self.confirm_email_within_text.split() From 90d52c656114ad9ca7bc5a80f205b412f63b7e8a Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 11 Jun 2012 18:06:47 -0400 Subject: [PATCH 032/234] token auth methods --- flask_security/tokens.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/flask_security/tokens.py b/flask_security/tokens.py index 73866629..f6695d23 100644 --- a/flask_security/tokens.py +++ b/flask_security/tokens.py @@ -3,7 +3,7 @@ from flask import current_app from flask.ext.security.exceptions import BadCredentialsError, \ - UserNotFoundError + UserNotFoundError, AuthenticationError from flask.ext.security.utils import generate_token from werkzeug.local import LocalProxy @@ -36,9 +36,15 @@ def generate_authentication_token(user): return user -def login_by_token(token): - pass +def authenticate_by_token(token): + try: + return find_user_by_authentication_token(token) + except UserNotFoundError: + raise BadCredentialsError('Invalid authentication token') + except Exception, e: + raise AuthenticationError(str(e)) def reset_authentication_token(user): - security.datastore._save_model(generate_authentication_token(user)) + user = generate_authentication_token(user) + security.datastore._save_model(user) From 727c19b9c06a7f7d44dcca36e7d429b1ae8d4df5 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 12 Jun 2012 12:11:19 -0400 Subject: [PATCH 033/234] Fix #13 --- flask_security/core.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index f741ac19..9be9103b 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -136,14 +136,10 @@ def decorated_view(*args, **kwargs): class RoleMixin(object): """Mixin for `Role` model definitions""" def __eq__(self, other): - if isinstance(other, basestring): - return self.name == other - return self.name == other.name + return self.name == getattr(other, 'name', None) def __ne__(self, other): - if isinstance(other, basestring): - return self.name != other - return self.name != other.name + return self.name != getattr(other, 'name', None) def __str__(self): return '' % self.name From 0141b240bc6206abbfc15e4c381e2d42d4caa1e2 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 12 Jun 2012 12:32:59 -0400 Subject: [PATCH 034/234] Better... --- flask_security/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 9be9103b..feddbe17 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -136,10 +136,10 @@ def decorated_view(*args, **kwargs): class RoleMixin(object): """Mixin for `Role` model definitions""" def __eq__(self, other): - return self.name == getattr(other, 'name', None) + return self.name == other or self.name == getattr(other, 'name', None) def __ne__(self, other): - return self.name != getattr(other, 'name', None) + return self.name != other and self.name != getattr(other, 'name', None) def __str__(self): return '' % self.name From c123e32ddcb5ee5f425a40c985e6cb04d48819a5 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 14 Jun 2012 18:04:14 -0400 Subject: [PATCH 035/234] decorators for basic http auth and token auth --- example/app.py | 23 +++++++++++-- flask_security/decorators.py | 64 ++++++++++++++++++++++++++++++++++++ flask_security/tokens.py | 28 ++++++++-------- tests/__init__.py | 5 +-- tests/functional_tests.py | 16 +++++++++ 5 files changed, 116 insertions(+), 20 deletions(-) create mode 100644 flask_security/decorators.py diff --git a/example/app.py b/example/app.py index 16cbbf7b..ee2e38e4 100644 --- a/example/app.py +++ b/example/app.py @@ -13,9 +13,11 @@ from flask.ext.mongoengine import MongoEngine from flask.ext.sqlalchemy import SQLAlchemy from flask.ext.security import Security, LoginForm, login_required, \ - roles_required, roles_accepted, UserMixin, RoleMixin + roles_required, roles_accepted, UserMixin, RoleMixin from flask.ext.security.datastore import SQLAlchemyUserDatastore, \ - MongoEngineUserDatastore + MongoEngineUserDatastore +from flask.ext.security.decorators import http_auth_required, \ + auth_token_required def create_roles(): @@ -29,7 +31,8 @@ def create_users(): ('jill@lp.com', 'password', ['author'], True), ('tiya@lp.com', 'password', [], False)): current_app.security.datastore.create_user( - email=u[0], password=u[1], roles=u[2], active=u[3]) + email=u[0], password=u[1], roles=u[2], active=u[3], + authentication_token='123abc') def populate_data(): @@ -70,6 +73,16 @@ def profile(): def post_login(): return render_template('index.html', content='Post Login') + @app.route('/http') + @http_auth_required + def http(): + return render_template('index.html', content='HTTP Authentication') + + @app.route('/token') + @auth_token_required + def token(): + return render_template('index.html', content='Token Authentication') + @app.route('/post_logout') def post_logout(): return render_template('index.html', content='Post Logout') @@ -116,6 +129,8 @@ class User(db.Model, UserMixin): confirmed_at = db.Column(db.DateTime()) reset_password_token = db.Column(db.String(255)) reset_password_sent_at = db.Column(db.DateTime()) + authentication_token = db.Column(db.String(255)) + authentication_token_created_at = db.Column(db.DateTime()) roles = db.relationship('Role', secondary=roles_users, backref=db.backref('users', lazy='dynamic')) @@ -151,6 +166,8 @@ class User(db.Document, UserMixin): confirmed_at = db.DateTimeField() reset_password_token = db.StringField(max_length=255) reset_password_sent_at = db.DateTimeField() + authentication_token = db.StringField(max_length=255) + authentication_token_created_at = db.DateTimeField() roles = db.ListField(db.ReferenceField(Role), default=[]) Security(app, MongoEngineUserDatastore(db, User, Role)) diff --git a/flask_security/decorators.py b/flask_security/decorators.py new file mode 100644 index 00000000..12b0461f --- /dev/null +++ b/flask_security/decorators.py @@ -0,0 +1,64 @@ + +from functools import wraps + +from flask import current_app as app, Response, request, abort + +_default_http_auth_msg = """ +

    Unauthorized

    +

    The server could not verify that you are authorized to access the URL + requested. You either supplied the wrong credentials (e.g. a bad password), + or your browser doesn't understand how to supply the credentials required.

    +

    In case you are allowed to request the document, please check your + user-id and password and try again.

    + """ + + +def _check_token(): + header_key = app.security.token_authentication_header + args_key = app.security.token_authentication_key + + header_token = request.headers.get(header_key, None) + token = request.args.get(args_key, header_token) + + try: + app.security.datastore.find_user(authentication_token=token) + except: + return False + + return True + + +def _check_http_auth(): + auth = request.authorization or dict(username=None, password=None) + + try: + user = app.security.datastore.find_user(email=auth.username) + except: + return False + + return app.security.pwd_context.verify(auth.password, user.password) + + +def http_auth_required(fn): + headers = {'WWW-Authenticate': 'Basic realm="Login Required"'} + + @wraps(fn) + def decorated(*args, **kwargs): + if _check_http_auth(): + return fn(*args, **kwargs) + + return Response(_default_http_auth_msg, 401, headers) + + return decorated + + +def auth_token_required(fn): + + @wraps(fn) + def decorated(*args, **kwargs): + if _check_token(): + return fn(*args, **kwargs) + + abort(401) + + return decorated diff --git a/flask_security/tokens.py b/flask_security/tokens.py index f6695d23..19b027fd 100644 --- a/flask_security/tokens.py +++ b/flask_security/tokens.py @@ -3,17 +3,17 @@ from flask import current_app from flask.ext.security.exceptions import BadCredentialsError, \ - UserNotFoundError, AuthenticationError + UserNotFoundError from flask.ext.security.utils import generate_token from werkzeug.local import LocalProxy -security = LocalProxy(lambda: current_app.security) +datastore = LocalProxy(lambda: current_app.security.datastore) def find_user_by_authentication_token(token): if not token: raise BadCredentialsError('Authentication token required') - return security.datastore.find_user(authentication_token=token) + return datastore.find_user(authentication_token=token) def generate_authentication_token(user): @@ -28,23 +28,21 @@ def generate_authentication_token(user): try: user['authentication_token'] = token - user['authentication_token_generated_at'] = now + user['authentication_token_created_at'] = now except TypeError: user.authentication_token = token - user.authentication_token_generated_at = now + user.authentication_token_created_at = now return user -def authenticate_by_token(token): - try: - return find_user_by_authentication_token(token) - except UserNotFoundError: - raise BadCredentialsError('Invalid authentication token') - except Exception, e: - raise AuthenticationError(str(e)) - - def reset_authentication_token(user): user = generate_authentication_token(user) - security.datastore._save_model(user) + datastore._save_model(user) + return user.authentication_token + + +def ensure_authentication_token(user): + if not user.authentication_token: + reset_authentication_token(user) + return user.authentication_token diff --git a/tests/__init__.py b/tests/__init__.py index e94077a6..63cdbbc6 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -18,9 +18,10 @@ def setUp(self): def _create_app(self, auth_config): return app.create_sqlalchemy_app(auth_config) - def _get(self, route, content_type=None, follow_redirects=None): + def _get(self, route, content_type=None, follow_redirects=None, headers=None): return self.client.get(route, follow_redirects=follow_redirects, - content_type=content_type or 'text/html') + content_type=content_type or 'text/html', + headers=headers) def _post(self, route, data=None, content_type=None, follow_redirects=True): return self.client.post(route, data=data, diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 177832c5..8ce19342 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -78,6 +78,22 @@ def test_unauthenticated_role_required(self): r = self._get('/admin', follow_redirects=True) self.assertIn(' Date: Fri, 15 Jun 2012 12:13:49 -0400 Subject: [PATCH 036/234] Polish up some code and add some signals --- flask_security/confirmable.py | 6 +++- flask_security/core.py | 10 +++++- flask_security/recoverable.py | 8 +++-- flask_security/signals.py | 8 +++++ flask_security/views.py | 58 ++++++++++++++++++++--------------- tests/functional_tests.py | 4 +-- 6 files changed, 63 insertions(+), 31 deletions(-) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 992c8dc3..c78ae757 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -5,10 +5,11 @@ from flask.ext.security.exceptions import UserNotFoundError, \ ConfirmationError, TokenExpiredError from flask.ext.security.utils import generate_token, send_mail +from flask.ext.security.signals import user_confirmed, \ + confirm_instructions_sent from werkzeug.local import LocalProxy security = LocalProxy(lambda: current_app.security) - logger = LocalProxy(lambda: current_app.logger) @@ -28,6 +29,8 @@ def send_confirmation_instructions(user): 'confirmation_instructions', dict(user=user, confirmation_link=confirmation_link)) + confirm_instructions_sent.send(user, app=current_app._get_current_object()) + return True @@ -89,6 +92,7 @@ def confirm_by_token(token): security.datastore._save_model(user) + user_confirmed.send(user, app=current_app._get_current_object()) return user diff --git a/flask_security/core.py b/flask_security/core.py index f73c7645..7bee6060 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -302,6 +302,9 @@ class ForgotPasswordForm(Form): email = TextField("Email Address", validators=[Required(message="Email not provided")]) + def to_dict(self): + return dict(email=self.email.data) + class LoginForm(Form): """The default login form""" @@ -334,13 +337,18 @@ def to_dict(self): class ResetPasswordForm(Form): - reset_password_token = HiddenField(validators=[Required()]) + token = HiddenField(validators=[Required()]) email = HiddenField(validators=[Required()]) password = PasswordField("Password", validators=[Required(message="Password not provided")]) password_confirm = PasswordField("Retype Password", validators=[EqualTo('password', message="Passwords do not match")]) + def to_dict(self): + return dict(token=self.token.data, + email=self.email.data, + password=self.password.data) + class AuthenticationProvider(object): """The default authentication provider implementation. diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 86cc4249..0ec377b1 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -4,12 +4,12 @@ from flask import current_app, request, url_for from flask.ext.security.exceptions import ResetPasswordError, \ UserNotFoundError, TokenExpiredError -from flask.ext.security.signals import password_reset_requested +from flask.ext.security.signals import password_reset, \ + password_reset_requested, confirm_instructions_sent from flask.ext.security.utils import generate_token, send_mail from werkzeug.local import LocalProxy security = LocalProxy(lambda: current_app.security) - logger = LocalProxy(lambda: current_app.logger) @@ -30,6 +30,8 @@ def send_reset_password_instructions(user): 'reset_instructions', dict(user=user, reset_link=reset_link)) + confirm_instructions_sent.send(user, app=current_app._get_current_object()) + return True @@ -75,6 +77,8 @@ def reset_by_token(token, email, password): send_mail('Your password has been reset', user.email, 'reset_notice') + password_reset.send(user, app=current_app._get_current_object()) + return user diff --git a/flask_security/signals.py b/flask_security/signals.py index 39901264..bc77265c 100644 --- a/flask_security/signals.py +++ b/flask_security/signals.py @@ -4,4 +4,12 @@ user_registered = signals.signal("user-registered") +user_confirmed = signals.signal("user-confirmed") + +confirm_instructions_sent = signals.signal("confirm-instructions-sent") + +password_reset = signals.signal("password-reset") + password_reset_requested = signals.signal("password-reset-requested") + +reset_instructions_sent = signals.signal("reset-instructions-sent") diff --git a/flask_security/views.py b/flask_security/views.py index 5a02c839..9e51c359 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -24,7 +24,7 @@ security = LocalProxy(lambda: current_app.security) - +datastore = LocalProxy(lambda: current_app.security.datastore) logger = LocalProxy(lambda: current_app.logger) @@ -63,7 +63,6 @@ def authenticate(): msg = 'Unknown authentication error' do_flash(msg, 'error') - logger.debug('Unsuccessful authentication attempt: %s' % msg) return redirect(request.referrer or security.login_manager.login_view) @@ -81,10 +80,9 @@ def logout(): identity=AnonymousIdentity()) logout_user() - logger.debug('User logged out') - return redirect(request.args.get('next', None) or \ + return redirect(request.args.get('next', None) or security.post_logout_view) @@ -99,24 +97,26 @@ def register(): form = security.RegisterForm(csrf_enabled=not current_app.testing) # Exit early if the form doesn't validate - if not form.validate_on_submit(): - return redirect(request.referrer or security.register_url) + if form.validate_on_submit(): + # Create user and send signal + user = datastore.create_user(**form.to_dict()) + app = current_app._get_current_object() + + user_registered.send(user, app=app) - # Create user and send signal - user = security.datastore.create_user(**form.to_dict()) - user_registered.send(user, app=current_app._get_current_object()) + # Send confirmation instructions if necessary + if security.confirm_email: + send_confirmation_instructions(user) - # Send confirmation instructions if necessary - if security.confirm_email: - send_confirmation_instructions(user) + logger.debug('User %s registered' % user) - logger.debug('User %s registered' % user) + # Login the user if allowed + if not security.confirm_email or security.login_without_confirmation: + _do_login(user) - # Login the user if allowed - if (not security.confirm_email) or security.login_without_confirmation: - _do_login(user) + return redirect(security.post_register_view or security.post_login_view) - return redirect(security.post_register_view or security.post_login_view) + return redirect(request.referrer or security.register_url) def confirm(): @@ -140,7 +140,6 @@ def confirm(): security.confirm_email_within_text, e.user.email) do_flash(msg, 'error') - return redirect('/') # TODO: Don't redirect to root logger.debug('User %s confirmed' % user) @@ -150,35 +149,44 @@ def confirm(): def forgot(): + """View function that handles the generation of a password reset token. + """ + form = security.ForgotPasswordForm(csrf_enabled=not current_app.testing) if form.validate_on_submit(): try: - user = security.datastore.find_user(email=form.email.data) + user = datastore.find_user(**form.to_dict()) + reset_password_reset_token(user) - do_flash('Instructions to reset your password have been sent to %s' % user.email, 'success') + + do_flash('Instructions to reset your password have been ' + 'sent to %s' % user.email, 'success') except UserNotFoundError: do_flash('The email you provided could not be found', 'error') return redirect(security.post_forgot_view) - return render_template('security/passwords/new.html', forgot_password_form=form) + return render_template('security/passwords/new.html', + forgot_password_form=form) def reset(): + """View function that handles the reset of a user's password. + """ + form = security.ResetPasswordForm(csrf_enabled=not current_app.testing) if form.validate_on_submit(): try: - reset_by_token(token=form.reset_password_token.data, - email=form.email.data, - password=form.password.data) + reset_by_token(**form.to_dict()) except ResetPasswordError, e: do_flash(str(e), 'error') except TokenExpiredError, e: - do_flash('You did not reset your password within %s.' % security.reset_password_within_text) + do_flash('You did not reset your password within' + '%s.' % security.reset_password_within_text) return redirect(request.referrer or security.reset_password_error_view) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 8ce19342..3a728d60 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -236,7 +236,7 @@ def test_reset_password_with_valid_token(self): r = self.client.post('/reset', data={ 'email': u.email, - 'reset_password_token': u.reset_password_token, + 'token': u.reset_password_token, 'password': 'newpassword', 'password_confirm': 'newpassword' }) @@ -251,7 +251,7 @@ def test_reset_password_twice_flashes_invalid_token_msg(self): data = { 'email': u.email, - 'reset_password_token': u.reset_password_token, + 'token': u.reset_password_token, 'password': 'newpassword', 'password_confirm': 'newpassword' } From b3a9364a97f30e34dcd63ec1e3f7bf180088bd4e Mon Sep 17 00:00:00 2001 From: Tristan Escalada Date: Fri, 15 Jun 2012 15:49:17 -0400 Subject: [PATCH 037/234] Switch from setup_app to init_app for flask_login new versions of flask_login have setup_app depricated --- flask_security/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/core.py b/flask_security/core.py index 7bee6060..ae6f7383 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -219,7 +219,7 @@ def init_app(self, app, datastore, registerable=True, recoverable=True): login_manager.anonymous_user = AnonymousUser login_manager.login_view = utils.config_value(app, 'LOGIN_VIEW') login_manager.user_loader(load_user) - login_manager.setup_app(app) + login_manager.init_app(app) Provider = utils.get_class_from_string(app, 'AUTH_PROVIDER') pw_hash = utils.config_value(app, 'PASSWORD_HASH') From 2655d67f5283b684fc1e08b55237068ba4eac4cb Mon Sep 17 00:00:00 2001 From: Tristan Escalada Date: Fri, 15 Jun 2012 15:50:34 -0400 Subject: [PATCH 038/234] Bugfix for generate_reset_password_token reset_password_token was being mixed with reset_password_sent_at --- flask_security/recoverable.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 0ec377b1..63875066 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -47,7 +47,7 @@ def generate_reset_password_token(user): try: user['reset_password_token'] = token - user['reset_password_token'] = now + user['reset_password_sent_at'] = now except TypeError: user.reset_password_token = token user.reset_password_sent_at = now From c20f244d6632e982d670757edd4b10979a28c0d6 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 18 Jun 2012 16:51:43 -0400 Subject: [PATCH 039/234] Big code cleanup --- flask_security/__init__.py | 10 ++- flask_security/confirmable.py | 43 ++++++---- flask_security/core.py | 154 +++------------------------------- flask_security/datastore.py | 3 +- flask_security/decorators.py | 91 +++++++++++++++++++- flask_security/forms.py | 66 +++++++++++++++ flask_security/recoverable.py | 46 ++++++---- flask_security/signals.py | 12 +++ flask_security/tokens.py | 26 ++++-- flask_security/utils.py | 4 +- flask_security/views.py | 84 ++++++++++--------- 11 files changed, 317 insertions(+), 222 deletions(-) create mode 100644 flask_security/forms.py diff --git a/flask_security/__init__.py b/flask_security/__init__.py index 74aa2140..27ff0391 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -10,4 +10,12 @@ :license: MIT, see LICENSE for more details. """ -from .core import * +from .core import Security, RoleMixin, UserMixin, AnonymousUser, \ + AuthenticationProvider +from .decorators import auth_token_required, http_auth_required, \ + login_required, roles_accepted, roles_required +from .forms import ForgotPasswordForm, LoginForm, RegisterForm, \ + ResetPasswordForm +from .signals import confirm_instructions_sent, password_reset, \ + password_reset_requested, reset_instructions_sent, user_confirmed, \ + user_registered diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index c78ae757..054a2b98 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -1,22 +1,34 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.confirmable + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security confirmable module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" from datetime import datetime -from flask import current_app, request, url_for -from flask.ext.security.exceptions import UserNotFoundError, \ - ConfirmationError, TokenExpiredError -from flask.ext.security.utils import generate_token, send_mail -from flask.ext.security.signals import user_confirmed, \ - confirm_instructions_sent +from flask import current_app as app, request, url_for from werkzeug.local import LocalProxy -security = LocalProxy(lambda: current_app.security) -logger = LocalProxy(lambda: current_app.logger) +from .exceptions import UserNotFoundError, ConfirmationError, TokenExpiredError +from .utils import generate_token, send_mail +from .signals import user_confirmed, confirm_instructions_sent + + +# Convenient references +_security = LocalProxy(lambda: app.security) + +_datastore = LocalProxy(lambda: app.security.datastore) def find_user_by_confirmation_token(token): if not token: raise ConfirmationError('Confirmation token required') - return security.datastore.find_user(confirmation_token=token) + return _datastore.find_user(confirmation_token=token) def send_confirmation_instructions(user): @@ -29,7 +41,7 @@ def send_confirmation_instructions(user): 'confirmation_instructions', dict(user=user, confirmation_link=confirmation_link)) - confirm_instructions_sent.send(user, app=current_app._get_current_object()) + confirm_instructions_sent.send(user, app=app._get_current_object()) return True @@ -56,7 +68,7 @@ def generate_confirmation_token(user): def should_confirm_email(fn): def wrapped(*args, **kwargs): - if security.confirm_email: + if _security.confirm_email: return fn(*args, **kwargs) return False return wrapped @@ -69,7 +81,7 @@ def requires_confirmation(user): @should_confirm_email def confirmation_token_is_expired(user): - token_expires = datetime.utcnow() - security.confirm_email_within + token_expires = datetime.utcnow() - _security.confirm_email_within return user.confirmation_sent_at < token_expires @@ -86,16 +98,17 @@ def confirm_by_token(token): raise TokenExpiredError(message='Confirmation token is expired', user=user) + # TODO: Clear confirmation_token after confirmation? #user.confirmation_token = None #user.confirmation_sent_at = None user.confirmed_at = datetime.utcnow() - security.datastore._save_model(user) + _datastore._save_model(user) - user_confirmed.send(user, app=current_app._get_current_object()) + user_confirmed.send(user, app=app._get_current_object()) return user def reset_confirmation_token(user): - security.datastore._save_model(generate_confirmation_token(user)) + _datastore._save_model(generate_confirmation_token(user)) send_confirmation_instructions(user) diff --git a/flask_security/core.py b/flask_security/core.py index 7bee6060..6784a765 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -10,22 +10,20 @@ """ from datetime import timedelta -from functools import wraps -from flask import current_app, Blueprint, redirect, request +from flask import current_app, Blueprint from flask.ext.login import AnonymousUser as AnonymousUserBase, \ - UserMixin as BaseUserMixin, LoginManager, login_required, \ - current_user, login_url -from flask.ext.principal import Principal, RoleNeed, UserNeed, \ - Permission, identity_loaded -from flask.ext.wtf import Form, TextField, PasswordField, SubmitField, \ - HiddenField, Required, BooleanField, EqualTo, Email -from flask.ext.security import views, exceptions, utils -from flask.ext.security.confirmable import confirmation_token_is_expired, \ - requires_confirmation, reset_confirmation_token + UserMixin as BaseUserMixin, LoginManager, current_user +from flask.ext.principal import Principal, RoleNeed, UserNeed, identity_loaded from passlib.context import CryptContext from werkzeug.datastructures import ImmutableList +from . import views, exceptions, utils +from .confirmable import confirmation_token_is_expired, requires_confirmation, \ + reset_confirmation_token +from .decorators import login_required +from .forms import Form, LoginForm + #: Default Flask-Security configuration _default_config = { @@ -33,10 +31,10 @@ 'FLASH_MESSAGES': True, 'PASSWORD_HASH': 'plaintext', 'AUTH_PROVIDER': 'flask.ext.security::AuthenticationProvider', - 'LOGIN_FORM': 'flask.ext.security::LoginForm', - 'REGISTER_FORM': 'flask.ext.security::RegisterForm', - 'RESET_PASSWORD_FORM': 'flask.ext.security::ResetPasswordForm', - 'FORGOT_PASSWORD_FORM': 'flask.ext.security::ForgotPasswordForm', + 'LOGIN_FORM': 'flask.ext.security.forms::LoginForm', + 'REGISTER_FORM': 'flask.ext.security.forms::RegisterForm', + 'RESET_PASSWORD_FORM': 'flask.ext.security.forms::ResetPasswordForm', + 'FORGOT_PASSWORD_FORM': 'flask.ext.security.forms::ForgotPasswordForm', 'AUTH_URL': '/auth', 'LOGOUT_URL': '/logout', 'REGISTER_URL': '/register', @@ -61,80 +59,6 @@ } -def roles_required(*roles): - """View decorator which specifies that a user must have all the specified - roles. Example:: - - @app.route('/dashboard') - @roles_required('admin', 'editor') - def dashboard(): - return 'Dashboard' - - The current user must have both the `admin` role and `editor` role in order - to view the page. - - :param args: The required roles. - """ - perm = Permission(*[RoleNeed(role) for role in roles]) - - def wrapper(fn): - @wraps(fn) - def decorated_view(*args, **kwargs): - if not current_user.is_authenticated(): - login_view = current_app.security.login_manager.login_view - return redirect(login_url(login_view, request.url)) - - if perm.can(): - return fn(*args, **kwargs) - - current_app.logger.debug('Identity does not provide the ' - 'roles: %s' % [r for r in roles]) - return redirect(request.referrer or '/') - return decorated_view - return wrapper - - -def roles_accepted(*roles): - """View decorator which specifies that a user must have at least one of the - specified roles. Example:: - - @app.route('/create_post') - @roles_accepted('editor', 'author') - def create_post(): - return 'Create Post' - - The current user must have either the `editor` role or `author` role in - order to view the page. - - :param args: The possible roles. - """ - perms = [Permission(RoleNeed(role)) for role in roles] - - def wrapper(fn): - @wraps(fn) - def decorated_view(*args, **kwargs): - if not current_user.is_authenticated(): - login_view = current_app.security.login_manager.login_view - return redirect(login_url(login_view, request.url)) - - for perm in perms: - if perm.can(): - return fn(*args, **kwargs) - - r1 = [r for r in roles] - r2 = [r.name for r in current_user.roles] - - current_app.logger.debug('Current user does not provide a ' - 'required role. Accepted: %s Provided: %s' % (r1, r2)) - - utils.do_flash('You do not have permission to ' - 'view this resource', 'error') - - return redirect(request.referrer or '/') - return decorated_view - return wrapper - - class RoleMixin(object): """Mixin for `Role` model definitions""" def __eq__(self, other): @@ -298,58 +222,6 @@ def setup_confirmable(self, bp): endpoint='confirm')(views.confirm) -class ForgotPasswordForm(Form): - email = TextField("Email Address", - validators=[Required(message="Email not provided")]) - - def to_dict(self): - return dict(email=self.email.data) - - -class LoginForm(Form): - """The default login form""" - - email = TextField("Email Address", - validators=[Required(message="Email not provided")]) - password = PasswordField("Password", - validators=[Required(message="Password not provided")]) - remember = BooleanField("Remember Me") - next = HiddenField() - submit = SubmitField("Login") - - def __init__(self, *args, **kwargs): - super(LoginForm, self).__init__(*args, **kwargs) - self.next.data = request.args.get('next', None) - - -class RegisterForm(Form): - """The default register form""" - - email = TextField("Email Address", - validators=[Required(message='Email not provided'), Email()]) - password = PasswordField("Password", - validators=[Required(message="Password not provided")]) - password_confirm = PasswordField("Retype Password", - validators=[EqualTo('password', message="Passwords do not match")]) - - def to_dict(self): - return dict(email=self.email.data, password=self.password.data) - - -class ResetPasswordForm(Form): - token = HiddenField(validators=[Required()]) - email = HiddenField(validators=[Required()]) - password = PasswordField("Password", - validators=[Required(message="Password not provided")]) - password_confirm = PasswordField("Retype Password", - validators=[EqualTo('password', message="Passwords do not match")]) - - def to_dict(self): - return dict(token=self.token.data, - email=self.email.data, - password=self.password.data) - - class AuthenticationProvider(object): """The default authentication provider implementation. diff --git a/flask_security/datastore.py b/flask_security/datastore.py index c47a59d8..67270841 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -10,7 +10,8 @@ """ from flask import current_app -from flask.ext.security import exceptions, confirmable + +from . import exceptions, confirmable class UserDatastore(object): diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 12b0461f..63ef946b 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -1,7 +1,22 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.decorators + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security decorators module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" from functools import wraps -from flask import current_app as app, Response, request, abort +from flask import current_app as app, Response, request, abort, redirect +from flask.ext.login import login_required, login_url, current_user +from flask.ext.principal import RoleNeed, Permission + +from . import utils + _default_http_auth_msg = """

    Unauthorized

    @@ -62,3 +77,77 @@ def decorated(*args, **kwargs): abort(401) return decorated + + +def roles_required(*roles): + """View decorator which specifies that a user must have all the specified + roles. Example:: + + @app.route('/dashboard') + @roles_required('admin', 'editor') + def dashboard(): + return 'Dashboard' + + The current user must have both the `admin` role and `editor` role in order + to view the page. + + :param args: The required roles. + """ + perm = Permission(*[RoleNeed(role) for role in roles]) + + def wrapper(fn): + @wraps(fn) + def decorated_view(*args, **kwargs): + if not current_user.is_authenticated(): + login_view = app.security.login_manager.login_view + return redirect(login_url(login_view, request.url)) + + if perm.can(): + return fn(*args, **kwargs) + + app.logger.debug('Identity does not provide the ' + 'roles: %s' % [r for r in roles]) + return redirect(request.referrer or '/') + return decorated_view + return wrapper + + +def roles_accepted(*roles): + """View decorator which specifies that a user must have at least one of the + specified roles. Example:: + + @app.route('/create_post') + @roles_accepted('editor', 'author') + def create_post(): + return 'Create Post' + + The current user must have either the `editor` role or `author` role in + order to view the page. + + :param args: The possible roles. + """ + perms = [Permission(RoleNeed(role)) for role in roles] + + def wrapper(fn): + @wraps(fn) + def decorated_view(*args, **kwargs): + if not current_user.is_authenticated(): + login_view = app.security.login_manager.login_view + return redirect(login_url(login_view, request.url)) + + for perm in perms: + if perm.can(): + return fn(*args, **kwargs) + + r1 = [r for r in roles] + r2 = [r.name for r in current_user.roles] + + app.logger.debug('Current user does not provide a ' + 'required role. Accepted: %s Provided: %s' % (r1, r2)) + + utils.do_flash('You do not have permission to ' + 'view this resource', 'error') + + return redirect(request.referrer or '/') + return decorated_view + return wrapper diff --git a/flask_security/forms.py b/flask_security/forms.py new file mode 100644 index 00000000..46cc4520 --- /dev/null +++ b/flask_security/forms.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.forms + ~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security forms module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + +from flask import request +from flask.ext.wtf import Form, TextField, PasswordField, SubmitField, \ + HiddenField, Required, BooleanField, EqualTo, Email + + +class ForgotPasswordForm(Form): + email = TextField("Email Address", + validators=[Required(message="Email not provided")]) + + def to_dict(self): + return dict(email=self.email.data) + + +class LoginForm(Form): + """The default login form""" + + email = TextField("Email Address", + validators=[Required(message="Email not provided")]) + password = PasswordField("Password", + validators=[Required(message="Password not provided")]) + remember = BooleanField("Remember Me") + next = HiddenField() + submit = SubmitField("Login") + + def __init__(self, *args, **kwargs): + super(LoginForm, self).__init__(*args, **kwargs) + self.next.data = request.args.get('next', None) + + +class RegisterForm(Form): + """The default register form""" + + email = TextField("Email Address", + validators=[Required(message='Email not provided'), Email()]) + password = PasswordField("Password", + validators=[Required(message="Password not provided")]) + password_confirm = PasswordField("Retype Password", + validators=[EqualTo('password', message="Passwords do not match")]) + + def to_dict(self): + return dict(email=self.email.data, password=self.password.data) + + +class ResetPasswordForm(Form): + token = HiddenField(validators=[Required()]) + email = HiddenField(validators=[Required()]) + password = PasswordField("Password", + validators=[Required(message="Password not provided")]) + password_confirm = PasswordField("Retype Password", + validators=[EqualTo('password', message="Passwords do not match")]) + + def to_dict(self): + return dict(token=self.token.data, + email=self.email.data, + password=self.password.data) diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 0ec377b1..38bea7f8 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -1,22 +1,36 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.recoverable + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security recoverable module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" from datetime import datetime -from flask import current_app, request, url_for -from flask.ext.security.exceptions import ResetPasswordError, \ - UserNotFoundError, TokenExpiredError -from flask.ext.security.signals import password_reset, \ - password_reset_requested, confirm_instructions_sent -from flask.ext.security.utils import generate_token, send_mail +from flask import current_app as app, request, url_for from werkzeug.local import LocalProxy -security = LocalProxy(lambda: current_app.security) -logger = LocalProxy(lambda: current_app.logger) +from .exceptions import ResetPasswordError, UserNotFoundError, \ + TokenExpiredError +from .signals import password_reset, password_reset_requested, \ + confirm_instructions_sent +from .utils import generate_token, send_mail + + +# Convenient references +_security = LocalProxy(lambda: app.security) + +_datastore = LocalProxy(lambda: app.security.datastore) def find_user_by_reset_token(token): if not token: raise ResetPasswordError('Reset password token required') - return security.datastore.find_user(reset_password_token=token) + return _datastore.find_user(reset_password_token=token) def send_reset_password_instructions(user): @@ -30,7 +44,7 @@ def send_reset_password_instructions(user): 'reset_instructions', dict(user=user, reset_link=reset_link)) - confirm_instructions_sent.send(user, app=current_app._get_current_object()) + confirm_instructions_sent.send(user, app=app._get_current_object()) return True @@ -56,7 +70,7 @@ def generate_reset_password_token(user): def password_reset_token_is_expired(user): - token_expires = datetime.utcnow() - security.reset_password_within + token_expires = datetime.utcnow() - _security.reset_password_within return user.reset_password_sent_at < token_expires @@ -71,18 +85,18 @@ def reset_by_token(token, email, password): user.reset_password_token = None user.reset_password_sent_at = None - user.password = security.pwd_context.encrypt(password) + user.password = _security.pwd_context.encrypt(password) - security.datastore._save_model(user) + _datastore._save_model(user) send_mail('Your password has been reset', user.email, 'reset_notice') - password_reset.send(user, app=current_app._get_current_object()) + password_reset.send(user, app=app._get_current_object()) return user def reset_password_reset_token(user): - security.datastore._save_model(generate_reset_password_token(user)) + _datastore._save_model(generate_reset_password_token(user)) send_reset_password_instructions(user) - password_reset_requested.send(user, app=current_app._get_current_object()) + password_reset_requested.send(user, app=app._get_current_object()) diff --git a/flask_security/signals.py b/flask_security/signals.py index bc77265c..7b14aafc 100644 --- a/flask_security/signals.py +++ b/flask_security/signals.py @@ -1,5 +1,17 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.signals + ~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security signals module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + import blinker + signals = blinker.Namespace() user_registered = signals.signal("user-registered") diff --git a/flask_security/tokens.py b/flask_security/tokens.py index 19b027fd..f68b98e1 100644 --- a/flask_security/tokens.py +++ b/flask_security/tokens.py @@ -1,19 +1,31 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.tokens + ~~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security tokens module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" from datetime import datetime -from flask import current_app -from flask.ext.security.exceptions import BadCredentialsError, \ - UserNotFoundError -from flask.ext.security.utils import generate_token +from flask import current_app as app from werkzeug.local import LocalProxy -datastore = LocalProxy(lambda: current_app.security.datastore) +from .exceptions import BadCredentialsError, UserNotFoundError +from .utils import generate_token + + +# Convenient references +_datastore = LocalProxy(lambda: app.security.datastore) def find_user_by_authentication_token(token): if not token: raise BadCredentialsError('Authentication token required') - return datastore.find_user(authentication_token=token) + return _datastore.find_user(authentication_token=token) def generate_authentication_token(user): @@ -38,7 +50,7 @@ def generate_authentication_token(user): def reset_authentication_token(user): user = generate_authentication_token(user) - datastore._save_model(user) + _datastore._save_model(user) return user.authentication_token diff --git a/flask_security/utils.py b/flask_security/utils.py index da80ad82..2b968ca1 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -11,12 +11,12 @@ import base64 import os - from contextlib import contextmanager from importlib import import_module from flask import url_for, flash, current_app, request, session, render_template -from flask.ext.security.signals import user_registered, password_reset_requested + +from .signals import user_registered, password_reset_requested def generate_token(): diff --git a/flask_security/views.py b/flask_security/views.py index 9e51c359..1efe7070 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -9,31 +9,36 @@ :license: MIT, see LICENSE for more details. """ -from flask import current_app, redirect, request, session, render_template +from flask import current_app as app, redirect, request, session, \ + render_template from flask.ext.login import login_user, logout_user from flask.ext.principal import Identity, AnonymousIdentity, identity_changed -from flask.ext.security.confirmable import confirm_by_token, \ +from werkzeug.local import LocalProxy + +from .confirmable import confirm_by_token, \ reset_confirmation_token, send_confirmation_instructions -from flask.ext.security.recoverable import reset_by_token, \ - reset_password_reset_token -from flask.ext.security.exceptions import TokenExpiredError, UserNotFoundError, \ +from .exceptions import TokenExpiredError, UserNotFoundError, \ ConfirmationError, BadCredentialsError, ResetPasswordError -from flask.ext.security.utils import get_post_login_redirect, do_flash -from flask.ext.security.signals import user_registered -from werkzeug.local import LocalProxy +from .recoverable import reset_by_token, \ + reset_password_reset_token +from .signals import user_registered +from .utils import get_post_login_redirect, do_flash + + +# Convenient references +_security = LocalProxy(lambda: app.security) +_datastore = LocalProxy(lambda: app.security.datastore) -security = LocalProxy(lambda: current_app.security) -datastore = LocalProxy(lambda: current_app.security.datastore) -logger = LocalProxy(lambda: current_app.logger) +_logger = LocalProxy(lambda: app.logger) def _do_login(user, remember=True): if login_user(user, remember): - identity_changed.send(current_app._get_current_object(), + identity_changed.send(app._get_current_object(), identity=Identity(user.id)) - logger.debug('User %s logged in' % user) + _logger.debug('User %s logged in' % user) return True return False @@ -46,10 +51,10 @@ def authenticate(): authenticate fails the user an appropriate error message is flashed and the user is redirected to the referring page or the login view. """ - form = security.LoginForm() + form = _security.LoginForm() try: - user = security.auth_provider.authenticate(form) + user = _security.auth_provider.authenticate(form) if _do_login(user, remember=form.remember.data): return redirect(get_post_login_redirect()) @@ -63,9 +68,9 @@ def authenticate(): msg = 'Unknown authentication error' do_flash(msg, 'error') - logger.debug('Unsuccessful authentication attempt: %s' % msg) + _logger.debug('Unsuccessful authentication attempt: %s' % msg) - return redirect(request.referrer or security.login_manager.login_view) + return redirect(request.referrer or _security.login_manager.login_view) def logout(): @@ -76,14 +81,14 @@ def logout(): for key in ('identity.name', 'identity.auth_type'): session.pop(key, None) - identity_changed.send(current_app._get_current_object(), + identity_changed.send(app._get_current_object(), identity=AnonymousIdentity()) logout_user() - logger.debug('User logged out') + _logger.debug('User logged out') return redirect(request.args.get('next', None) or - security.post_logout_view) + _security.post_logout_view) def register(): @@ -94,29 +99,30 @@ def register(): Otherwise the user is redirected to the `SECURITY_POST_LOGIN_VIEW` configuration value. """ - form = security.RegisterForm(csrf_enabled=not current_app.testing) + form = _security.RegisterForm(csrf_enabled=not app.testing) # Exit early if the form doesn't validate if form.validate_on_submit(): # Create user and send signal - user = datastore.create_user(**form.to_dict()) - app = current_app._get_current_object() + user = _datastore.create_user(**form.to_dict()) - user_registered.send(user, app=app) + user_registered.send(user, app=app._get_current_object()) # Send confirmation instructions if necessary - if security.confirm_email: + if _security.confirm_email: send_confirmation_instructions(user) - logger.debug('User %s registered' % user) + _logger.debug('User %s registered' % user) # Login the user if allowed - if not security.confirm_email or security.login_without_confirmation: + if not _security.confirm_email or _security.login_without_confirmation: _do_login(user) - return redirect(security.post_register_view or security.post_login_view) + return redirect(_security.post_register_view or + _security.post_login_view) - return redirect(request.referrer or security.register_url) + return redirect(request.referrer or + _security.register_url) def confirm(): @@ -137,26 +143,27 @@ def confirm(): msg = 'You did not confirm your email within %s. ' \ 'A new confirmation code has been sent to %s' % ( - security.confirm_email_within_text, e.user.email) + _security.confirm_email_within_text, e.user.email) do_flash(msg, 'error') return redirect('/') # TODO: Don't redirect to root - logger.debug('User %s confirmed' % user) + _logger.debug('User %s confirmed' % user) do_flash('Your email has been confirmed. You may now log in.', 'success') - return redirect(security.post_confirm_view or security.post_login_view) + return redirect(_security.post_confirm_view or + _security.post_login_view) def forgot(): """View function that handles the generation of a password reset token. """ - form = security.ForgotPasswordForm(csrf_enabled=not current_app.testing) + form = _security.ForgotPasswordForm(csrf_enabled=not app.testing) if form.validate_on_submit(): try: - user = datastore.find_user(**form.to_dict()) + user = _datastore.find_user(**form.to_dict()) reset_password_reset_token(user) @@ -166,7 +173,7 @@ def forgot(): except UserNotFoundError: do_flash('The email you provided could not be found', 'error') - return redirect(security.post_forgot_view) + return redirect(_security.post_forgot_view) return render_template('security/passwords/new.html', forgot_password_form=form) @@ -176,7 +183,7 @@ def reset(): """View function that handles the reset of a user's password. """ - form = security.ResetPasswordForm(csrf_enabled=not current_app.testing) + form = _security.ResetPasswordForm(csrf_enabled=not app.testing) if form.validate_on_submit(): try: @@ -187,6 +194,7 @@ def reset(): except TokenExpiredError, e: do_flash('You did not reset your password within' - '%s.' % security.reset_password_within_text) + '%s.' % _security.reset_password_within_text) - return redirect(request.referrer or security.reset_password_error_view) + return redirect(request.referrer or + _security.reset_password_error_view) From 421cf410a2eb6ffcb095730a2fe803b157b5a0b3 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 18 Jun 2012 16:58:56 -0400 Subject: [PATCH 040/234] Specify dependency versions in setup.py --- setup.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index 15476dee..0dfd6080 100644 --- a/setup.py +++ b/setup.py @@ -31,12 +31,12 @@ include_package_data=True, platforms='any', install_requires=[ - 'Flask', - 'Flask-Login', - 'Flask-Principal', - 'Flask-WTF', - 'Flask-Mail', - 'passlib' + 'Flask>=0.8', + 'Flask-Login==0.1.3', + 'Flask-Principal==0.2', + 'Flask-WTF==0.5.4', + 'Flask-Mail==0.6.1', + 'passlib==1.5.3' ], test_suite='nose.collector', tests_require=[ From efc93979d190a72659ea278abe7b18b6550fee5b Mon Sep 17 00:00:00 2001 From: Tristan Escalada Date: Mon, 18 Jun 2012 19:57:51 -0400 Subject: [PATCH 041/234] Adding back lost role/active functions some functions must have gotten lost. Added them back. _do_add_role, _do_remove_role, _do_toggle_active, _do_deactive_user, _do_active_user, _prepare_role_modify_args --- flask_security/datastore.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 67270841..c0ef5a8c 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -45,6 +45,38 @@ def _do_find_role(self, **kwargs): raise NotImplementedError( "User datastore does not implement _do_find_role method") + def _do_add_role(self, user, role): + user, role = self._prepare_role_modify_args(user, role) + if role not in user.roles: + user.roles.append(role) + return user + + def _do_remove_role(self, user, role): + user, role = self._prepare_role_modify_args(user, role) + if role in user.roles: + user.roles.remove(role) + return user + + def _do_toggle_active(self, user, active=None): + user = self.find_user(email=user.email) + if active is None: + user.active = not user.active + elif active != user.active: + user.active = active + return user + + def _do_deactive_user(self, user): + return self._do_toggle_active(user, False) + + def _do_active_user(self, user): + return self._do_toggle_active(user, True) + + def _prepare_role_modify_args(self, user, role): + if isinstance(role, self.role_model): + role = role.name + + return self.find_user(email=user.email), self.find_role(role) + def _prepare_create_role_args(self, kwargs): if kwargs['name'] is None: raise exceptions.RoleCreationError("Missing name argument") From 173189111359d93dc7b16d27ee7f32a6d8a72c27 Mon Sep 17 00:00:00 2001 From: Tristan Escalada Date: Mon, 18 Jun 2012 21:30:33 -0400 Subject: [PATCH 042/234] Re-adding current_user passing current_user from flask-login to __init__.py --- flask_security/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/__init__.py b/flask_security/__init__.py index 27ff0391..dfce324b 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -11,7 +11,7 @@ """ from .core import Security, RoleMixin, UserMixin, AnonymousUser, \ - AuthenticationProvider + AuthenticationProvider, current_user from .decorators import auth_token_required, http_auth_required, \ login_required, roles_accepted, roles_required from .forms import ForgotPasswordForm, LoginForm, RegisterForm, \ From a902530773239ecec79abf3479ffb9d8a0daab3e Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 19 Jun 2012 11:55:23 -0400 Subject: [PATCH 043/234] Refactor forms and import other commonly used functions into package --- flask_security/__init__.py | 2 ++ flask_security/forms.py | 64 +++++++++++++++++++++++------------ flask_security/recoverable.py | 1 + tests/functional_tests.py | 6 +++- 4 files changed, 51 insertions(+), 22 deletions(-) diff --git a/flask_security/__init__.py b/flask_security/__init__.py index dfce324b..3464c24c 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -10,6 +10,8 @@ :license: MIT, see LICENSE for more details. """ +from flask.ext.login import login_user, logout_user + from .core import Security, RoleMixin, UserMixin, AnonymousUser, \ AuthenticationProvider, current_user from .decorators import auth_token_required, http_auth_required, \ diff --git a/flask_security/forms.py b/flask_security/forms.py index 46cc4520..1b780c74 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -14,51 +14,73 @@ HiddenField, Required, BooleanField, EqualTo, Email -class ForgotPasswordForm(Form): +class EmailFormMixin(): email = TextField("Email Address", - validators=[Required(message="Email not provided")]) + validators=[Required(message="Email not provided"), + Email(message="Invalid email address")]) + + +class PasswordFormMixin(): + password = PasswordField("Password", + validators=[Required(message="Password not provided")]) + + +class PasswordConfirmFormMixin(): + password_confirm = PasswordField("Retype Password", + validators=[EqualTo('password', message="Passwords do not match")]) + + +class ForgotPasswordForm(Form, EmailFormMixin): + """The default forgot password form""" + + submit = SubmitField("Recover Password") def to_dict(self): return dict(email=self.email.data) -class LoginForm(Form): +class LoginForm(Form, EmailFormMixin, PasswordFormMixin): """The default login form""" - email = TextField("Email Address", - validators=[Required(message="Email not provided")]) - password = PasswordField("Password", - validators=[Required(message="Password not provided")]) remember = BooleanField("Remember Me") next = HiddenField() submit = SubmitField("Login") def __init__(self, *args, **kwargs): super(LoginForm, self).__init__(*args, **kwargs) - self.next.data = request.args.get('next', None) + + if request.method == 'GET': + self.next.data = request.args.get('next', None) -class RegisterForm(Form): +class RegisterForm(Form, + EmailFormMixin, + PasswordFormMixin, + PasswordConfirmFormMixin): """The default register form""" - email = TextField("Email Address", - validators=[Required(message='Email not provided'), Email()]) - password = PasswordField("Password", - validators=[Required(message="Password not provided")]) - password_confirm = PasswordField("Retype Password", - validators=[EqualTo('password', message="Passwords do not match")]) + submit = SubmitField("Register") def to_dict(self): return dict(email=self.email.data, password=self.password.data) -class ResetPasswordForm(Form): +class ResetPasswordForm(Form, + EmailFormMixin, + PasswordFormMixin, + PasswordConfirmFormMixin): + """The default reset password form""" + token = HiddenField(validators=[Required()]) - email = HiddenField(validators=[Required()]) - password = PasswordField("Password", - validators=[Required(message="Password not provided")]) - password_confirm = PasswordField("Retype Password", - validators=[EqualTo('password', message="Passwords do not match")]) + + submit = SubmitField("Reset Password") + + def __init__(self, *args, **kwargs): + super(ResetPasswordForm, self).__init__(*args, **kwargs) + + if request.method == 'GET': + self.token.data = request.args.get('token', None) + self.email.data = request.args.get('email', None) def to_dict(self): return dict(token=self.token.data, diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 9a2e4cd7..a53144ab 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -35,6 +35,7 @@ def find_user_by_reset_token(token): def send_reset_password_instructions(user): url = url_for('flask_security.reset', + email=user.email, reset_token=user.reset_password_token) reset_link = request.url_root[:-1] + url diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 3a728d60..0905faf9 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -27,8 +27,12 @@ def test_unprovided_password(self): r = self.authenticate(password="") self.assertIn("Password not provided", r.data) - def test_invalid_user(self): + def test_invalid_email(self): r = self.authenticate(email="bogus") + self.assertIn("Invalid email address", r.data) + + def test_invalid_user(self): + r = self.authenticate(email="bogus@bogus.com") self.assertIn("Specified user does not exist", r.data) def test_bad_password(self): From 3044d8a5ba0760cbf47f7acbe23d4648fcf6de6c Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 19 Jun 2012 14:51:06 -0400 Subject: [PATCH 044/234] Add travis --- .travis.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..4bd50a82 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: python + +python: + - 2.5 + - 2.6 + - 2.7 + +install: + - pip install Flask Flask-Login Flask-Principal Flask-WTF Flask-Mail passlib nose Flask-SQLAlchemy Flask-MongoEngine pybcrypt --use-mirrors + - pip install . --use-mirrors + +script: nosetests \ No newline at end of file From e61b4e47f581b552396193c44512f59a19415354 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 19 Jun 2012 15:02:28 -0400 Subject: [PATCH 045/234] More travis stuff --- .travis.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 4bd50a82..952e8d78 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,4 +9,11 @@ install: - pip install Flask Flask-Login Flask-Principal Flask-WTF Flask-Mail passlib nose Flask-SQLAlchemy Flask-MongoEngine pybcrypt --use-mirrors - pip install . --use-mirrors -script: nosetests \ No newline at end of file +before_script: + - mysql -e 'create database flask_security_test;' + +script: nosetests + +branches: + only: + - develop \ No newline at end of file From babcb6e9876884da49e9703230384aa096230897 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 19 Jun 2012 15:17:40 -0400 Subject: [PATCH 046/234] Rename to rst --- README => README.rst | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename README => README.rst (100%) diff --git a/README b/README.rst similarity index 100% rename from README rename to README.rst From a4622002d9bfcabfbb7490ba2b2d0719b33bbc5b Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 19 Jun 2012 15:21:13 -0400 Subject: [PATCH 047/234] Update README.rst --- README.rst | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 5f8ac9cc..627b0e89 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,11 @@ Flask-Security -============== +=========== + +|build status|_ + +.. |build status| image:: https://secure.travis-ci.org/mattupstate/flask-security.png?branch=develop + :alt: Build Status +.. _build status: http://travis-ci.org/mattupstate/flask-security Simple security for Flask applications combining Flask-Login, Flask-Principal, Flask-WTF, passlib, and your choice of datastore. Currently SQLAlchemy via @@ -8,4 +14,11 @@ box. You will need to install the necessary Flask extensions that you'll be using. Additionally, you may need to install an encryption library such as py-bcrypt to support bcrypt passwords. -Documentation: http://packages.python.org/Flask-Security/ \ No newline at end of file +Resources +--------- + +- `Documentation` `_ +- `Issue Tracker `_ +- `Code `_ +- `Development Version + `_ \ No newline at end of file From 022a6e5159f3e6696fc660d0935794e5c495cb73 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 19 Jun 2012 16:29:23 -0400 Subject: [PATCH 048/234] Update travis config --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 952e8d78..820d1d08 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,7 @@ python: - 2.7 install: - - pip install Flask Flask-Login Flask-Principal Flask-WTF Flask-Mail passlib nose Flask-SQLAlchemy Flask-MongoEngine pybcrypt --use-mirrors + - pip install Flask Flask-Login Flask-Principal Flask-WTF Flask-Mail passlib nose Flask-SQLAlchemy Flask-MongoEngine py-bcrypt --use-mirrors - pip install . --use-mirrors before_script: From 442afd07f6dda4577e6168b07bf1fafc995521b3 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 20 Jun 2012 10:44:17 -0400 Subject: [PATCH 049/234] Update travis build --- .travis.yml | 4 ++-- requirements.txt | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 requirements.txt diff --git a/.travis.yml b/.travis.yml index 820d1d08..37907ff0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,7 @@ python: - 2.7 install: - - pip install Flask Flask-Login Flask-Principal Flask-WTF Flask-Mail passlib nose Flask-SQLAlchemy Flask-MongoEngine py-bcrypt --use-mirrors + - pip install -r requirements.txt --use-mirrors - pip install . --use-mirrors before_script: @@ -16,4 +16,4 @@ script: nosetests branches: only: - - develop \ No newline at end of file + - develop diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..3794005a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,29 @@ +Flask==0.8 +Flask-Login==0.1.3 +Flask-Mail==0.6.1 +Flask-Principal==0.2 +Flask-SQLAlchemy==0.15 +Flask-Script==0.3.2 +Flask-WTF==0.5.4 +Jinja2==2.6 +MySQL-python==1.2.3 +PyYAML==3.10 +Pygments==1.5 +SQLAlchemy==0.7.8 +Sphinx==1.1.3 +WTForms==1.0.1 +Werkzeug==0.8.3 +argparse==1.2.1 +blinker==1.2 +chardet==1.0.1 +docutils==0.9.1 +flask-mongoengine==0.3 +lamson==1.1 +lockfile==0.9.1 +mock==0.8.0 +mongoengine==0.6.12 +nose==1.1.2 +passlib==1.5.3 +pymongo==2.2 +python-daemon==1.6 +wsgiref==0.1.2 From a7016c1cfa3ac96459b8906fbe84f14a6583bb6d Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 20 Jun 2012 15:36:44 -0400 Subject: [PATCH 050/234] Update build and dependencies --- .gitignore | 29 ++++++++++++++++++++++++----- .travis.yml | 13 +++++++------ README.rst | 8 ++------ requirements.txt | 29 ----------------------------- setup.py | 12 +++++++----- tests/functional_tests.py | 2 ++ 6 files changed, 42 insertions(+), 51 deletions(-) delete mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index daf25454..23d1773c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,27 @@ -.DS_Store -*.pyc +*.py[co] + +# Packages *.egg *.egg-info -.project -.pydevproject -.settings dist +*build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox + +#Translations +*.mo + +#Mr Developer +.mr.developer.cfg diff --git a/.travis.yml b/.travis.yml index 37907ff0..472865e2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,14 @@ language: python python: - - 2.5 - - 2.6 - - 2.7 + - "2.5" + - "2.6" + - "2.7" -install: - - pip install -r requirements.txt --use-mirrors - - pip install . --use-mirrors +install: + - pip install . --quiet --use-mirrors + - pip install nose Flask-SQLAlchemy Flask-MongoEngine py-bcrypt --quiet --use-mirrors + - "if [[ $TRAVIS_PYTHON_VERSION != '2.7' ]]; then pip install importlib --quiet --use-mirrors; fi" before_script: - mysql -e 'create database flask_security_test;' diff --git a/README.rst b/README.rst index 627b0e89..12151e52 100644 --- a/README.rst +++ b/README.rst @@ -1,11 +1,7 @@ Flask-Security -=========== +============== -|build status|_ - -.. |build status| image:: https://secure.travis-ci.org/mattupstate/flask-security.png?branch=develop - :alt: Build Status -.. _build status: http://travis-ci.org/mattupstate/flask-security +.. image:: https://secure.travis-ci.org/mattupstate/flask-security.png?branch=develop Simple security for Flask applications combining Flask-Login, Flask-Principal, Flask-WTF, passlib, and your choice of datastore. Currently SQLAlchemy via diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 3794005a..00000000 --- a/requirements.txt +++ /dev/null @@ -1,29 +0,0 @@ -Flask==0.8 -Flask-Login==0.1.3 -Flask-Mail==0.6.1 -Flask-Principal==0.2 -Flask-SQLAlchemy==0.15 -Flask-Script==0.3.2 -Flask-WTF==0.5.4 -Jinja2==2.6 -MySQL-python==1.2.3 -PyYAML==3.10 -Pygments==1.5 -SQLAlchemy==0.7.8 -Sphinx==1.1.3 -WTForms==1.0.1 -Werkzeug==0.8.3 -argparse==1.2.1 -blinker==1.2 -chardet==1.0.1 -docutils==0.9.1 -flask-mongoengine==0.3 -lamson==1.1 -lockfile==0.9.1 -mock==0.8.0 -mongoengine==0.6.12 -nose==1.1.2 -passlib==1.5.3 -pymongo==2.2 -python-daemon==1.6 -wsgiref==0.1.2 diff --git a/setup.py b/setup.py index 0dfd6080..e4d9ad67 100644 --- a/setup.py +++ b/setup.py @@ -8,6 +8,8 @@ Links ````` +* `documentation `_ +* `source `_ * `development version `_ @@ -32,11 +34,11 @@ platforms='any', install_requires=[ 'Flask>=0.8', - 'Flask-Login==0.1.3', - 'Flask-Principal==0.2', - 'Flask-WTF==0.5.4', - 'Flask-Mail==0.6.1', - 'passlib==1.5.3' + 'Flask-Login>=0.1.3', + 'Flask-Principal>=0.3', + 'Flask-WTF>=0.5.4', + 'Flask-Mail>=0.6.1', + 'passlib>=1.5.3' ], test_suite='nose.collector', tests_require=[ diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 0905faf9..b3100ab2 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from __future__ import with_statement + from datetime import datetime, timedelta from flask.ext.security.utils import capture_registrations, \ From 10322602dad9148e95467841d221761a01288d2d Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 20 Jun 2012 15:53:44 -0400 Subject: [PATCH 051/234] Update build --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 472865e2..37004a2a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,8 +7,8 @@ python: install: - pip install . --quiet --use-mirrors - - pip install nose Flask-SQLAlchemy Flask-MongoEngine py-bcrypt --quiet --use-mirrors - - "if [[ $TRAVIS_PYTHON_VERSION != '2.7' ]]; then pip install importlib --quiet --use-mirrors; fi" + - pip install nose Flask-SQLAlchemy Flask-MongoEngine py-bcrypt MySQL-python --quiet --use-mirrors + - "if [[ $TRAVIS_PYTHON_VERSION != '2.7' ]]; then pip install importlib simplejson --quiet --use-mirrors; fi" before_script: - mysql -e 'create database flask_security_test;' From 5034e7b4f6a91a838063d75cb786c143c9c08d2c Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 20 Jun 2012 16:21:35 -0400 Subject: [PATCH 052/234] Add test methods that are missing for Python versions < 2.7 --- tests/__init__.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/__init__.py b/tests/__init__.py index 63cdbbc6..7af88b90 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -41,3 +41,21 @@ def logout(self, endpoint=None): def assertIsHomePage(self, data): self.assertIn('Home Page', data) + + def assertIn(self, member, container, msg=None): + if hasattr(TestCase, 'assertIn'): + return TestCase.assertIn(self, member, container, msg) + + return self.assertTrue(member in container) + + def assertNotIn(self, member, container, msg=None): + if hasattr(TestCase, 'assertNotIn'): + return TestCase.assertNotIn(self, member, container, msg) + + return self.assertFalse(member in container) + + def assertIsNotNone(self, obj, msg=None): + if hasattr(TestCase, 'assertIsNotNone'): + return TestCase.assertIsNotNone(self, obj, msg) + + return self.assertTrue(obj is not None) From 7f2a05c364e3a76da746bfd2b68eee3f28bed04d Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 20 Jun 2012 16:35:36 -0400 Subject: [PATCH 053/234] Try and fix an issue with python 2.5 --- example/app.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/example/app.py b/example/app.py index ee2e38e4..74fc1728 100644 --- a/example/app.py +++ b/example/app.py @@ -30,9 +30,14 @@ def create_users(): ('joe@lp.com', 'password', ['editor'], True), ('jill@lp.com', 'password', ['author'], True), ('tiya@lp.com', 'password', [], False)): - current_app.security.datastore.create_user( - email=u[0], password=u[1], roles=u[2], active=u[3], - authentication_token='123abc') + current_app.security.datastore.create_user(**{ + 'email': u[0], + 'password': u[1], + 'roles': u[2], + 'active': u[3], + 'authentication_token': + '123abc' + }) def populate_data(): From 0a8ee87319d10dcd17146b351dad928a9239fb2f Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 20 Jun 2012 16:51:09 -0400 Subject: [PATCH 054/234] Try using an older version of mongoengine during build to get python 2.5 tests to pass --- .travis.yml | 3 ++- example/app.py | 11 +++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index 37004a2a..82ebd0cb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,8 +7,9 @@ python: install: - pip install . --quiet --use-mirrors - - pip install nose Flask-SQLAlchemy Flask-MongoEngine py-bcrypt MySQL-python --quiet --use-mirrors - "if [[ $TRAVIS_PYTHON_VERSION != '2.7' ]]; then pip install importlib simplejson --quiet --use-mirrors; fi" + - "if [[ $TRAVIS_PYTHON_VERSION == '2.6' ]]; then pip install mongoengine==0.6.5 --quiet --use-mirrors; fi" + - pip install nose Flask-SQLAlchemy Flask-MongoEngine py-bcrypt MySQL-python --quiet --use-mirrors before_script: - mysql -e 'create database flask_security_test;' diff --git a/example/app.py b/example/app.py index 74fc1728..ee2e38e4 100644 --- a/example/app.py +++ b/example/app.py @@ -30,14 +30,9 @@ def create_users(): ('joe@lp.com', 'password', ['editor'], True), ('jill@lp.com', 'password', ['author'], True), ('tiya@lp.com', 'password', [], False)): - current_app.security.datastore.create_user(**{ - 'email': u[0], - 'password': u[1], - 'roles': u[2], - 'active': u[3], - 'authentication_token': - '123abc' - }) + current_app.security.datastore.create_user( + email=u[0], password=u[1], roles=u[2], active=u[3], + authentication_token='123abc') def populate_data(): From e67cbee1d527c17b344977d7fdd3c9df77fb8df2 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 20 Jun 2012 17:12:07 -0400 Subject: [PATCH 055/234] Oops, 2.5 not 2.6 --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 82ebd0cb..de5567fe 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,7 @@ python: install: - pip install . --quiet --use-mirrors - "if [[ $TRAVIS_PYTHON_VERSION != '2.7' ]]; then pip install importlib simplejson --quiet --use-mirrors; fi" - - "if [[ $TRAVIS_PYTHON_VERSION == '2.6' ]]; then pip install mongoengine==0.6.5 --quiet --use-mirrors; fi" + - "if [[ $TRAVIS_PYTHON_VERSION == '2.5' ]]; then pip install mongoengine==0.6.5 --quiet --use-mirrors; fi" - pip install nose Flask-SQLAlchemy Flask-MongoEngine py-bcrypt MySQL-python --quiet --use-mirrors before_script: From 24cd4938a5da26938b67eec52196eba67d34915a Mon Sep 17 00:00:00 2001 From: David Ignacio Date: Fri, 22 Jun 2012 00:15:43 -0500 Subject: [PATCH 056/234] correct roles_* decorator signature expectations Having multiple RoleNeed objects in a Permission does not require all to be satisfied in order to .can(), but will return True if any are present. This makes the previous roles_required logic more elegant for roles_accepted. roles_required decorator needs to check all permissions individually and return only if all permissions exist --- example/app.py | 6 ++++++ flask_security/decorators.py | 21 ++++++++++----------- tests/functional_tests.py | 10 ++++++++++ 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/example/app.py b/example/app.py index ee2e38e4..e0beb909 100644 --- a/example/app.py +++ b/example/app.py @@ -28,6 +28,7 @@ def create_roles(): def create_users(): for u in (('matt@lp.com', 'password', ['admin'], True), ('joe@lp.com', 'password', ['editor'], True), + ('dave@lp.com', 'password', ['admin', 'editor'], True), ('jill@lp.com', 'password', ['author'], True), ('tiya@lp.com', 'password', [], False)): current_app.security.datastore.create_user( @@ -96,6 +97,11 @@ def post_register(): def admin(): return render_template('index.html', content='Admin Page') + @app.route('/admin_and_editor') + @roles_required('admin', 'editor') + def admin_and_editor(): + return render_template('index.html', content='Admin and Editor Page') + @app.route('/admin_or_editor') @roles_accepted('admin', 'editor') def admin_or_editor(): diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 63ef946b..8a9b6183 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -93,7 +93,7 @@ def dashboard(): :param args: The required roles. """ - perm = Permission(*[RoleNeed(role) for role in roles]) + perms = [Permission(RoleNeed(role)) for role in roles] def wrapper(fn): @wraps(fn) @@ -102,12 +102,12 @@ def decorated_view(*args, **kwargs): login_view = app.security.login_manager.login_view return redirect(login_url(login_view, request.url)) - if perm.can(): - return fn(*args, **kwargs) - - app.logger.debug('Identity does not provide the ' - 'roles: %s' % [r for r in roles]) - return redirect(request.referrer or '/') + for perm in perms: + if not perm.can(): + app.logger.debug('Identity does not provide the ' + 'roles: %s' % [r for r in roles]) + return redirect(request.referrer or '/') + return fn(*args, **kwargs) return decorated_view return wrapper @@ -126,7 +126,7 @@ def create_post(): :param args: The possible roles. """ - perms = [Permission(RoleNeed(role)) for role in roles] + perm = Permission(*[RoleNeed(role) for role in roles]) def wrapper(fn): @wraps(fn) @@ -135,9 +135,8 @@ def decorated_view(*args, **kwargs): login_view = app.security.login_manager.login_view return redirect(login_url(login_view, request.url)) - for perm in perms: - if perm.can(): - return fn(*args, **kwargs) + if perm.can(): + return fn(*args, **kwargs) r1 = [r for r in roles] r2 = [r.name for r in current_user.roles] diff --git a/tests/functional_tests.py b/tests/functional_tests.py index b3100ab2..c824cfc3 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -84,6 +84,16 @@ def test_unauthenticated_role_required(self): r = self._get('/admin', follow_redirects=True) self.assertIn(' Date: Fri, 29 Jun 2012 11:29:33 -0400 Subject: [PATCH 057/234] Add doc strings --- flask_security/utils.py | 48 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/flask_security/utils.py b/flask_security/utils.py index 2b968ca1..96a03a25 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -20,16 +20,27 @@ def generate_token(): + """Generate an arbitrary URL safe token""" return base64.urlsafe_b64encode(os.urandom(30)) -def do_flash(message, category): +def do_flash(message, category=None): + """Flash a message depending on if the `FLASH_MESSAGES` configuration + value is set. + + :param message: The flash message + :param category: The flash message category + """ if config_value(current_app, 'FLASH_MESSAGES'): flash(message, category) def get_class_from_string(app, key): - """Get a reference to a class by its configuration key name.""" + """Get a reference to a class by its configuration key name. + + :param app: The application instance to work against + :param key: The configuration key to use + """ cv = config_value(app, key).split('::') cm = import_module(cv[0]) return getattr(cm, cv[1]) @@ -37,7 +48,10 @@ def get_class_from_string(app, key): def get_url(endpoint_or_url): """Returns a URL if a valid endpoint is found. Otherwise, returns the - provided value.""" + provided value. + + :param endpoint_or_url: The endpoint name or URL to default to + """ try: return url_for(endpoint_or_url) except: @@ -52,7 +66,10 @@ def get_post_login_redirect(): def find_redirect(key): - """Returns the URL to redirect to after a user logs in successfully""" + """Returns the URL to redirect to after a user logs in successfully + + :param key: The session or application configuration key to search for + """ result = (get_url(session.pop(key.lower(), None)) or get_url(current_app.config[key.upper()] or None) or '/') @@ -62,10 +79,23 @@ def find_redirect(key): def config_value(app, key, default=None): + """Get a Flask-Security configuration value + + :param app: The application to retrieve the configuration from + :param key: The configuration key without the prefix `SECURITY_` + :param default: An optional default value if the value is not set + """ return app.config.get('SECURITY_' + key.upper(), default) def send_mail(subject, recipient, template, context=None): + """Send an email via the Flask-Mail extension + + :param subject: Email subject + :param recipient: Email recipient + :param template: The name of the email template + :param context: The context to render the template with + """ from flask.ext.mail import Message context = context or {} @@ -83,6 +113,11 @@ def send_mail(subject, recipient, template, context=None): @contextmanager def capture_registrations(confirmation_sent_at=None): + """Testing utility for capturing registrations + + :param confirmation_sent_at: An optional datetime object to set the + user's `confirmation_sent_at` to + """ users = [] def _on(user, app): @@ -102,6 +137,11 @@ def _on(user, app): @contextmanager def capture_reset_password_requests(reset_password_sent_at=None): + """Testing utility for capturing password reset requests + + :param reset_password_sent_at: An optional datetime object to set the + user's `reset_password_sent_at` to + """ users = [] def _on(user, app): From 2ea835ec9ffb0010abde3d719f9356dd111d934b Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 29 Jun 2012 12:37:22 -0400 Subject: [PATCH 058/234] Add a bunch of doc strings and add some more configuration values --- flask_security/confirmable.py | 28 ++++++++++++- flask_security/core.py | 75 ++++++++++++++++++++--------------- flask_security/datastore.py | 10 +++-- flask_security/decorators.py | 9 +++-- flask_security/recoverable.py | 29 ++++++++++++++ flask_security/tokens.py | 17 ++++++++ flask_security/utils.py | 14 +++---- flask_security/views.py | 40 ++++++------------- tests/functional_tests.py | 2 +- 9 files changed, 148 insertions(+), 76 deletions(-) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 054a2b98..a160d215 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -26,12 +26,20 @@ def find_user_by_confirmation_token(token): + """Returns a user with a matching confirmation token. + + :param token: The reset password token + """ if not token: raise ConfirmationError('Confirmation token required') return _datastore.find_user(confirmation_token=token) def send_confirmation_instructions(user): + """Sends the confirmation instructions email for the specified user. + + :param user: The user to send the instructions to + """ url = url_for('flask_security.confirm', confirmation_token=user.confirmation_token) @@ -47,6 +55,10 @@ def send_confirmation_instructions(user): def generate_confirmation_token(user): + """Generates a unique confirmation token for the specified user. + + :param user: The user to work with + """ while True: token = generate_token() try: @@ -67,8 +79,9 @@ def generate_confirmation_token(user): def should_confirm_email(fn): + """Handy decorator that returns early if confirmation should not occur.""" def wrapped(*args, **kwargs): - if _security.confirm_email: + if _security.confirmable: return fn(*args, **kwargs) return False return wrapped @@ -76,16 +89,24 @@ def wrapped(*args, **kwargs): @should_confirm_email def requires_confirmation(user): + """Returns `True` if the user requires confirmation.""" return user.confirmed_at == None @should_confirm_email def confirmation_token_is_expired(user): + """Returns `True` if the user's confirmation token is expired.""" token_expires = datetime.utcnow() - _security.confirm_email_within return user.confirmation_sent_at < token_expires def confirm_by_token(token): + """Confirm the user given the specified token. If the token is invalid or + the user is already confirmed a `ConfirmationError` error will be raised. + If the token is expired a `TokenExpiredError` error will be raised. + + :param token: The user's confirmation token + """ try: user = find_user_by_confirmation_token(token) except UserNotFoundError: @@ -110,5 +131,10 @@ def confirm_by_token(token): def reset_confirmation_token(user): + """Resets the specified user's confirmation token and sends the user + an email with instructions explaining next steps. + + :param user: The user to work with + """ _datastore._save_model(generate_confirmation_token(user)) send_confirmation_instructions(user) diff --git a/flask_security/core.py b/flask_security/core.py index bee13553..4f5af9f4 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -49,7 +49,9 @@ 'POST_REGISTER_VIEW': None, 'POST_CONFIRM_VIEW': None, 'DEFAULT_ROLES': [], - 'CONFIRM_EMAIL': False, + 'CONFIRMABLE': False, + 'REGISTERABLE': True, + 'RECOVERABLE': True, 'CONFIRM_EMAIL_WITHIN': '5 days', 'RESET_PASSWORD_WITHIN': '2 days', 'LOGIN_WITHOUT_CONFIRMATION': False, @@ -90,6 +92,8 @@ def __str__(self): class AnonymousUser(AnonymousUserBase): + """AnonymousUser definition""" + def __init__(self): super(AnonymousUser, self).__init__() self.roles = ImmutableList() @@ -99,7 +103,7 @@ def has_role(self, *args): return False -def load_user(user_id): +def _load_user(user_id): try: return current_app.security.datastore.with_id(user_id) except Exception, e: @@ -107,7 +111,7 @@ def load_user(user_id): return None -def on_identity_loaded(sender, identity): +def _on_identity_loaded(sender, identity): if hasattr(current_user, 'id'): identity.provides.add(UserNeed(current_user.id)) @@ -126,12 +130,16 @@ class Security(object): def __init__(self, app=None, datastore=None, **kwargs): self.init_app(app, datastore, **kwargs) - def init_app(self, app, datastore, registerable=True, recoverable=True): + def init_app(self, app, datastore): """Initializes the Flask-Security extension for the specified application and datastore implentation. :param app: The application. :param datastore: An instance of a user datastore. + :param confirmable: Set to `True` to enable email confirmation + :param registerable: Set to `False` to disable registration endpoints + :param recoverable: Set to `False` to disable password recovery + endpoints """ if app is None or datastore is None: return @@ -142,7 +150,7 @@ def init_app(self, app, datastore, registerable=True, recoverable=True): login_manager = LoginManager() login_manager.anonymous_user = AnonymousUser login_manager.login_view = utils.config_value(app, 'LOGIN_VIEW') - login_manager.user_loader(load_user) + login_manager.user_loader(_load_user) login_manager.init_app(app) Provider = utils.get_class_from_string(app, 'AUTH_PROVIDER') @@ -150,7 +158,7 @@ def init_app(self, app, datastore, registerable=True, recoverable=True): self.login_manager = login_manager self.pwd_context = CryptContext(schemes=[pw_hash], default=pw_hash) - self.auth_provider = Provider(Form) + self.auth_provider = Provider() self.principal = Principal(app) self.datastore = datastore self.LoginForm = utils.get_class_from_string(app, 'LOGIN_FORM') @@ -171,7 +179,9 @@ def init_app(self, app, datastore, registerable=True, recoverable=True): self.reset_password_error_view = utils.config_value(app, 'RESET_PASSWORD_ERROR_VIEW') self.default_roles = utils.config_value(app, "DEFAULT_ROLES") self.login_without_confirmation = utils.config_value(app, 'LOGIN_WITHOUT_CONFIRMATION') - self.confirm_email = utils.config_value(app, 'CONFIRM_EMAIL') + self.confirmable = utils.config_value(app, 'CONFIRMABLE') + self.registerable = utils.config_value(app, 'REGISTERABLE') + self.recoverable = utils.config_value(app, 'RECOVERABLE') self.email_sender = utils.config_value(app, 'EMAIL_SENDER') self.token_authentication_key = utils.config_value(app, 'TOKEN_AUTHENTICATION_KEY') self.token_authentication_header = utils.config_value(app, 'TOKEN_AUTHENTICATION_HEADER') @@ -184,7 +194,7 @@ def init_app(self, app, datastore, registerable=True, recoverable=True): values = self.reset_password_within_text.split() self.reset_password_within = timedelta(**{values[1]: int(values[0])}) - identity_loaded.connect_via(app)(on_identity_loaded) + identity_loaded.connect_via(app)(_on_identity_loaded) bp = Blueprint('flask_security', __name__, template_folder='templates') @@ -195,21 +205,21 @@ def init_app(self, app, datastore, registerable=True, recoverable=True): bp.route(self.logout_url, endpoint='logout')(login_required(views.logout)) - self.setup_registerable(bp) if registerable else None - self.setup_recoverable(bp) if recoverable else None - self.setup_confirmable(bp) if self.confirm_email else None + self._setup_registerable(bp) if self.registerable else None + self._setup_recoverable(bp) if self.recoverable else None + self._setup_confirmable(bp) if self.confirmable else None app.register_blueprint(bp, url_prefix=utils.config_value(app, 'URL_PREFIX')) app.security = self - def setup_registerable(self, bp): + def _setup_registerable(self, bp): bp.route(self.register_url, methods=['POST'], endpoint='register')(views.register) - def setup_recoverable(self, bp): + def _setup_recoverable(self, bp): bp.route(self.forgot_url, methods=['POST'], endpoint='forgot')(views.forgot) @@ -217,32 +227,30 @@ def setup_recoverable(self, bp): methods=['POST'], endpoint='reset')(views.reset) - def setup_confirmable(self, bp): + def _setup_confirmable(self, bp): bp.route(self.confirm_url, endpoint='confirm')(views.confirm) class AuthenticationProvider(object): - """The default authentication provider implementation. - - :param login_form_class: The login form class to use when authenticating a - user - """ - - def __init__(self, login_form_class=None): - self.login_form_class = login_form_class or LoginForm + """The default authentication provider implementation.""" + def _get_user(self, username_or_email): + datastore = current_app.security.datastore - def login_form(self, formdata=None): - """Returns an instance of the login form with the provided form. - - :param formdata: The incoming form data""" - return self.login_form_class(formdata) + try: + return datastore.find_user(email=username_or_email) + except exceptions.UserNotFoundError: + try: + return datastore.find_user(username=username_or_email) + except: + raise exceptions.UserNotFoundError() def authenticate(self, form): """Processes an authentication request and returns a user instance if authentication is successful. - :param form: An instance of a populated login form + :param form: A populated WTForm instance that contains `email` and + `password` form fields """ if not form.validate(): if form.email.errors: @@ -252,15 +260,16 @@ def authenticate(self, form): return self.do_authenticate(form.email.data, form.password.data) - def do_authenticate(self, email, password): + def do_authenticate(self, username_or_email, password): """Returns the authenticated user if authentication is successfull. If - authentication fails an appropriate error is raised + authentication fails an appropriate `AuthenticationError` is raised - :param user_identifier: The user's identifier, usuall an email address - :param password: The user's unencrypted password + :param username_or_email: The username or email address of the user + :param password: The password supplied by the authentication request """ + try: - user = current_app.security.datastore.find_user(email=email) + user = self._get_user(username_or_email) except AttributeError, e: self.auth_error("Could not find user datastore: %s" % e) except exceptions.UserNotFoundError, e: diff --git a/flask_security/datastore.py b/flask_security/datastore.py index c0ef5a8c..90d214ac 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -90,7 +90,7 @@ def _prepare_create_user_args(self, kwargs): kwargs.setdefault('active', True) kwargs.setdefault('roles', current_app.security.default_roles) - if current_app.security.confirm_email: + if current_app.security.confirmable: confirmable.generate_confirmation_token(kwargs) if email is None: @@ -196,7 +196,9 @@ def activate_user(self, user, commit=True): class SQLAlchemyUserDatastore(UserDatastore): - """A SQLAlchemy datastore implementation for Flask-Security. + """A SQLAlchemy datastore implementation for Flask-Security that assumes the + use of the Flask-SQLAlchemy extension. + Example usage:: from flask import Flask @@ -249,7 +251,9 @@ def _do_find_role(self, role): class MongoEngineUserDatastore(UserDatastore): - """A MongoEngine datastore implementation for Flask-Security. + """A MongoEngine datastore implementation for Flask-Security that assumes + the use of the Flask-MongoEngine extension. + Example usage:: from flask import Flask diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 63ef946b..f7f05f83 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -55,6 +55,7 @@ def _check_http_auth(): def http_auth_required(fn): + """Decorator that protects endpoints using Basic HTTP authentication.""" headers = {'WWW-Authenticate': 'Basic realm="Login Required"'} @wraps(fn) @@ -68,7 +69,7 @@ def decorated(*args, **kwargs): def auth_token_required(fn): - + """Decorator that protects endpoints using token authentication.""" @wraps(fn) def decorated(*args, **kwargs): if _check_token(): @@ -80,8 +81,8 @@ def decorated(*args, **kwargs): def roles_required(*roles): - """View decorator which specifies that a user must have all the specified - roles. Example:: + """Decorator which specifies that a user must have all the specified roles. + Example:: @app.route('/dashboard') @roles_required('admin', 'editor') @@ -113,7 +114,7 @@ def decorated_view(*args, **kwargs): def roles_accepted(*roles): - """View decorator which specifies that a user must have at least one of the + """Decorator which specifies that a user must have at least one of the specified roles. Example:: @app.route('/create_post') diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index a53144ab..617cce9a 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -28,12 +28,20 @@ def find_user_by_reset_token(token): + """Returns a user with a matching reset password token. + + :param token: The reset password token + """ if not token: raise ResetPasswordError('Reset password token required') return _datastore.find_user(reset_password_token=token) def send_reset_password_instructions(user): + """Sends the reset password instructions email for the specified user. + + :param user: The user to send the instructions to + """ url = url_for('flask_security.reset', email=user.email, reset_token=user.reset_password_token) @@ -51,6 +59,10 @@ def send_reset_password_instructions(user): def generate_reset_password_token(user): + """Generates a unique reset password token for the specified user. + + :param user: The user to work with + """ while True: token = generate_token() try: @@ -71,11 +83,23 @@ def generate_reset_password_token(user): def password_reset_token_is_expired(user): + """Returns `True` if the specified user's reset password token is expired. + + :param user: The user to examine + """ token_expires = datetime.utcnow() - _security.reset_password_within return user.reset_password_sent_at < token_expires def reset_by_token(token, email, password): + """Resets the password of the user given the specified token, email and + password. If the token is invalid a `ResetPasswordError` error will be + raised. If the token is expired a `TokenExpiredError` error will be raised. + + :param token: The user's reset password token + :param email: The user's email address + :param password: The user's new password + """ try: user = find_user_by_reset_token(token) except UserNotFoundError: @@ -98,6 +122,11 @@ def reset_by_token(token, email, password): def reset_password_reset_token(user): + """Resets the specified user's reset password token and sends the user + an email with instructions explaining next steps. + + :param user: The user to work with + """ _datastore._save_model(generate_reset_password_token(user)) send_reset_password_instructions(user) password_reset_requested.send(user, app=app._get_current_object()) diff --git a/flask_security/tokens.py b/flask_security/tokens.py index f68b98e1..081bf2ab 100644 --- a/flask_security/tokens.py +++ b/flask_security/tokens.py @@ -23,12 +23,20 @@ def find_user_by_authentication_token(token): + """Returns a user with a matching authentication token. + + :param token: The authentication token + """ if not token: raise BadCredentialsError('Authentication token required') return _datastore.find_user(authentication_token=token) def generate_authentication_token(user): + """Generates a unique authentication token for the specified user. + + :param user: The user to work with + """ while True: token = generate_token() try: @@ -49,12 +57,21 @@ def generate_authentication_token(user): def reset_authentication_token(user): + """Resets a user's authentication token and returns the new token value. + + :param user: The user to work with + """ user = generate_authentication_token(user) _datastore._save_model(user) return user.authentication_token def ensure_authentication_token(user): + """Ensures that a user has an authentication token. If the user has an + authentication token already, nothing is performed. + + :param user: The user to work with + """ if not user.authentication_token: reset_authentication_token(user) return user.authentication_token diff --git a/flask_security/utils.py b/flask_security/utils.py index 96a03a25..77abb1ff 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -20,7 +20,7 @@ def generate_token(): - """Generate an arbitrary URL safe token""" + """Generate an arbitrary URL safe token.""" return base64.urlsafe_b64encode(os.urandom(30)) @@ -59,14 +59,14 @@ def get_url(endpoint_or_url): def get_post_login_redirect(): - """Returns the URL to redirect to after a user logs in successfully""" + """Returns the URL to redirect to after a user logs in successfully.""" return (get_url(request.args.get('next')) or get_url(request.form.get('next')) or find_redirect('SECURITY_POST_LOGIN_VIEW')) def find_redirect(key): - """Returns the URL to redirect to after a user logs in successfully + """Returns the URL to redirect to after a user logs in successfully. :param key: The session or application configuration key to search for """ @@ -79,7 +79,7 @@ def find_redirect(key): def config_value(app, key, default=None): - """Get a Flask-Security configuration value + """Get a Flask-Security configuration value. :param app: The application to retrieve the configuration from :param key: The configuration key without the prefix `SECURITY_` @@ -89,7 +89,7 @@ def config_value(app, key, default=None): def send_mail(subject, recipient, template, context=None): - """Send an email via the Flask-Mail extension + """Send an email via the Flask-Mail extension. :param subject: Email subject :param recipient: Email recipient @@ -113,7 +113,7 @@ def send_mail(subject, recipient, template, context=None): @contextmanager def capture_registrations(confirmation_sent_at=None): - """Testing utility for capturing registrations + """Testing utility for capturing registrations. :param confirmation_sent_at: An optional datetime object to set the user's `confirmation_sent_at` to @@ -137,7 +137,7 @@ def _on(user, app): @contextmanager def capture_reset_password_requests(reset_password_sent_at=None): - """Testing utility for capturing password reset requests + """Testing utility for capturing password reset requests. :param reset_password_sent_at: An optional datetime object to set the user's `reset_password_sent_at` to diff --git a/flask_security/views.py b/flask_security/views.py index 1efe7070..425955ad 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -34,6 +34,8 @@ def _do_login(user, remember=True): + """Performs the login and sends the appropriate signal.""" + if login_user(user, remember): identity_changed.send(app._get_current_object(), identity=Identity(user.id)) @@ -44,13 +46,8 @@ def _do_login(user, remember=True): def authenticate(): - """View function which handles an authentication attempt. If authentication - is successful the user is redirected to, if set, the value of the `next` - form parameter. If that value is not set the user is redirected to the - value of the `SECURITY_POST_LOGIN_VIEW` configuration value. If - authenticate fails the user an appropriate error message is flashed and - the user is redirected to the referring page or the login view. - """ + """View function which handles an authentication request.""" + form = _security.LoginForm() try: @@ -74,10 +71,8 @@ def authenticate(): def logout(): - """View function which logs out the current user. When completed the user - is redirected to the value of the `next` query string parameter or the - `SECURITY_POST_LOGIN_VIEW` configuration value. - """ + """View function which handles a logout request.""" + for key in ('identity.name', 'identity.auth_type'): session.pop(key, None) @@ -92,13 +87,8 @@ def logout(): def register(): - """View function which registers a new user and, if configured so, the user - isautomatically logged in. If required confirmation instructions are sent - via email. After registration is completed the user is redirected to, if - set, the value of the `SECURITY_POST_REGISTER_VIEW` configuration value. - Otherwise the user is redirected to the `SECURITY_POST_LOGIN_VIEW` - configuration value. - """ + """View function which handles a registration request.""" + form = _security.RegisterForm(csrf_enabled=not app.testing) # Exit early if the form doesn't validate @@ -109,13 +99,13 @@ def register(): user_registered.send(user, app=app._get_current_object()) # Send confirmation instructions if necessary - if _security.confirm_email: + if _security.confirmable: send_confirmation_instructions(user) _logger.debug('User %s registered' % user) # Login the user if allowed - if not _security.confirm_email or _security.login_without_confirmation: + if not _security.confirmable or _security.login_without_confirmation: _do_login(user) return redirect(_security.post_register_view or @@ -126,9 +116,7 @@ def register(): def confirm(): - """View function which confirms a user's email address using a token taken - from the value of the `confirmation_token` query string argument. - """ + """View function which handles a account confirmation request.""" try: token = request.args.get('confirmation_token', None) @@ -156,8 +144,7 @@ def confirm(): def forgot(): - """View function that handles the generation of a password reset token. - """ + """View function that handles a forgotten password request.""" form = _security.ForgotPasswordForm(csrf_enabled=not app.testing) @@ -180,8 +167,7 @@ def forgot(): def reset(): - """View function that handles the reset of a user's password. - """ + """View function that handles a reset password request.""" form = _security.ResetPasswordForm(csrf_enabled=not app.testing) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index b3100ab2..89a097d7 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -142,7 +142,7 @@ def test_register_valid_user(self): class ConfirmableTests(SecurityTest): AUTH_CONFIG = { - 'SECURITY_CONFIRM_EMAIL': True + 'SECURITY_CONFIRMABLE': True } def test_register_sends_confirmation_email(self): From 2ed0ea48e6ad922dcf304b5d0e21ff2ad0db6027 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 9 Jul 2012 18:57:43 -0400 Subject: [PATCH 059/234] Add trackable option and make extra features default to off/False to minimize about of application setup to get started --- example/app.py | 12 ++++++++++++ flask_security/core.py | 24 +++++++++++++++++++----- flask_security/views.py | 19 ++++++++++++++++++- tests/functional_tests.py | 14 ++++++++++++-- 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/example/app.py b/example/app.py index e0beb909..3ec090a7 100644 --- a/example/app.py +++ b/example/app.py @@ -129,6 +129,12 @@ class User(db.Model, UserMixin): id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(255), unique=True) password = db.Column(db.String(120)) + remember_token = db.Column(db.String(255)) + last_login_at = db.Column(db.DateTime()) + current_login_at = db.Column(db.DateTime()) + last_login_ip = db.Column(db.String(100)) + current_login_ip = db.Column(db.String(100)) + login_count = db.Column(db.Integer) active = db.Column(db.Boolean()) confirmation_token = db.Column(db.String(255)) confirmation_sent_at = db.Column(db.DateTime()) @@ -166,6 +172,12 @@ class Role(db.Document, RoleMixin): class User(db.Document, UserMixin): email = db.StringField(unique=True, max_length=255) password = db.StringField(required=True, max_length=120) + remember_token = db.StringField(max_length=255) + last_login_at = db.DateTimeField() + current_login_at = db.DateTimeField() + last_login_ip = db.StringField(max_length=100) + current_login_ip = db.StringField(max_length=100) + login_count = db.IntField() active = db.BooleanField(default=True) confirmation_token = db.StringField(max_length=255) confirmation_sent_at = db.DateTimeField() diff --git a/flask_security/core.py b/flask_security/core.py index 4f5af9f4..cc451f25 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -22,7 +22,6 @@ from .confirmable import confirmation_token_is_expired, requires_confirmation, \ reset_confirmation_token from .decorators import login_required -from .forms import Form, LoginForm #: Default Flask-Security configuration @@ -50,8 +49,9 @@ 'POST_CONFIRM_VIEW': None, 'DEFAULT_ROLES': [], 'CONFIRMABLE': False, - 'REGISTERABLE': True, - 'RECOVERABLE': True, + 'REGISTERABLE': False, + 'RECOVERABLE': False, + 'TRACKABLE': False, 'CONFIRM_EMAIL_WITHIN': '5 days', 'RESET_PASSWORD_WITHIN': '2 days', 'LOGIN_WITHOUT_CONFIRMATION': False, @@ -80,6 +80,10 @@ def is_active(self): """Returns `True` if the user is active.""" return self.active + def get_auth_token(self): + """Returns the user's authentication token.""" + self.remember_token + def has_role(self, role): """Returns `True` if the user identifies with the specified role. @@ -103,7 +107,7 @@ def has_role(self, *args): return False -def _load_user(user_id): +def _user_loader(user_id): try: return current_app.security.datastore.with_id(user_id) except Exception, e: @@ -111,6 +115,14 @@ def _load_user(user_id): return None +def _token_loader(token): + try: + return current_app.security.datastore.find_user(remember_token=token) + except Exception, e: + current_app.logger.error('Error getting user: %s' % e) + return None + + def _on_identity_loaded(sender, identity): if hasattr(current_user, 'id'): identity.provides.add(UserNeed(current_user.id)) @@ -150,7 +162,8 @@ def init_app(self, app, datastore): login_manager = LoginManager() login_manager.anonymous_user = AnonymousUser login_manager.login_view = utils.config_value(app, 'LOGIN_VIEW') - login_manager.user_loader(_load_user) + login_manager.user_loader(_user_loader) + login_manager.token_loader(_token_loader) login_manager.init_app(app) Provider = utils.get_class_from_string(app, 'AUTH_PROVIDER') @@ -182,6 +195,7 @@ def init_app(self, app, datastore): self.confirmable = utils.config_value(app, 'CONFIRMABLE') self.registerable = utils.config_value(app, 'REGISTERABLE') self.recoverable = utils.config_value(app, 'RECOVERABLE') + self.trackable = utils.config_value(app, 'TRACKABLE') self.email_sender = utils.config_value(app, 'EMAIL_SENDER') self.token_authentication_key = utils.config_value(app, 'TOKEN_AUTHENTICATION_KEY') self.token_authentication_header = utils.config_value(app, 'TOKEN_AUTHENTICATION_HEADER') diff --git a/flask_security/views.py b/flask_security/views.py index 425955ad..9d5c9539 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -9,9 +9,11 @@ :license: MIT, see LICENSE for more details. """ +from datetime import datetime + from flask import current_app as app, redirect, request, session, \ render_template -from flask.ext.login import login_user, logout_user +from flask.ext.login import login_user, logout_user, make_secure_token from flask.ext.principal import Identity, AnonymousIdentity, identity_changed from werkzeug.local import LocalProxy @@ -37,6 +39,21 @@ def _do_login(user, remember=True): """Performs the login and sends the appropriate signal.""" if login_user(user, remember): + user.remember_token = make_secure_token(user.email, user.password) + + if _security.trackable: + old_current, new_current = user.current_login_at, datetime.utcnow() + user.last_login_at = old_current or new_current + user.current_login_at = new_current + + old_current, new_current = user.current_login_ip, request.remote_addr + user.last_login_ip = old_current or new_current + user.current_login_ip = new_current + + user.login_count = user.login_count + 1 if user.login_count else 0 + + _datastore._save_model(user) + identity_changed.send(app._get_current_object(), identity=Identity(user.id)) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index de254619..62725b48 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -114,6 +114,7 @@ def test_token_auth_via_header_invalid_token(self): class ConfiguredURLTests(SecurityTest): AUTH_CONFIG = { + 'SECURITY_REGISTERABLE': True, 'SECURITY_AUTH_URL': '/custom_auth', 'SECURITY_LOGOUT_URL': '/custom_logout', 'SECURITY_LOGIN_VIEW': '/custom_login', @@ -142,6 +143,9 @@ def test_register(self): class RegisterableTests(SecurityTest): + AUTH_CONFIG = { + 'SECURITY_REGISTERABLE': True + } def test_register_valid_user(self): data = dict(email='dude@lp.com', password='password', password_confirm='password') @@ -152,7 +156,8 @@ def test_register_valid_user(self): class ConfirmableTests(SecurityTest): AUTH_CONFIG = { - 'SECURITY_CONFIRMABLE': True + 'SECURITY_CONFIRMABLE': True, + 'SECURITY_REGISTERABLE': True } def test_register_sends_confirmation_email(self): @@ -216,7 +221,8 @@ def test_expired_confirmation_token_sends_email(self): class LoginWithoutImmediateConfirmTests(SecurityTest): AUTH_CONFIG = { - 'SECURITY_CONFIRM_EMAIL': True, + 'SECURITY_CONFIRMABLE': True, + 'SECURITY_REGISTERABLE': True, 'SECURITY_LOGIN_WITHOUT_CONFIRMATION': True } @@ -230,6 +236,10 @@ def test_register_valid_user_automatically_signs_in(self): class RecoverableTests(SecurityTest): + AUTH_CONFIG = { + 'SECURITY_RECOVERABLE': True + } + def test_forgot_post_sends_email_and_sets_required_fields(self): with capture_reset_password_requests() as users: with self.app.mail.record_messages() as outbox: From 9bba330f2cbd55bb4d8b078c9a22b5a9df8f9869 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 9 Jul 2012 18:58:31 -0400 Subject: [PATCH 060/234] Fix README.rst typo --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 12151e52..31b0bf6b 100644 --- a/README.rst +++ b/README.rst @@ -13,7 +13,7 @@ py-bcrypt to support bcrypt passwords. Resources --------- -- `Documentation` `_ +- `Documentation `_ - `Issue Tracker `_ - `Code `_ - `Development Version From 86447ab0c92eab26a338452630bc1a05288d5d71 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 9 Jul 2012 19:10:12 -0400 Subject: [PATCH 061/234] Tiny refactor --- flask_security/views.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/flask_security/views.py b/flask_security/views.py index 9d5c9539..78056347 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -38,28 +38,30 @@ def _do_login(user, remember=True): """Performs the login and sends the appropriate signal.""" - if login_user(user, remember): - user.remember_token = make_secure_token(user.email, user.password) + if not login_user(user, remember): + return False - if _security.trackable: - old_current, new_current = user.current_login_at, datetime.utcnow() - user.last_login_at = old_current or new_current - user.current_login_at = new_current + user.remember_token = None if not remember else \ + make_secure_token(user.email, user.password) - old_current, new_current = user.current_login_ip, request.remote_addr - user.last_login_ip = old_current or new_current - user.current_login_ip = new_current + if _security.trackable: + old_current, new_current = user.current_login_at, datetime.utcnow() + user.last_login_at = old_current or new_current + user.current_login_at = new_current - user.login_count = user.login_count + 1 if user.login_count else 0 + old_current, new_current = user.current_login_ip, request.remote_addr + user.last_login_ip = old_current or new_current + user.current_login_ip = new_current - _datastore._save_model(user) + user.login_count = user.login_count + 1 if user.login_count else 0 - identity_changed.send(app._get_current_object(), - identity=Identity(user.id)) + _datastore._save_model(user) - _logger.debug('User %s logged in' % user) - return True - return False + identity_changed.send(app._get_current_object(), + identity=Identity(user.id)) + + _logger.debug('User %s logged in' % user) + return True def authenticate(): From 4f557dac4cc44101fe437e7ebfa6ebe8e67c47d4 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 10 Jul 2012 14:39:43 -0400 Subject: [PATCH 062/234] Update template a bit --- flask_security/templates/security/confirmations/new.html | 8 +++++++- flask_security/templates/security/logins/new.html | 2 ++ flask_security/templates/security/messages.html | 9 +++++++++ flask_security/templates/security/passwords/edit.html | 2 ++ flask_security/templates/security/passwords/new.html | 2 ++ .../templates/security/registrations/edit.html | 2 ++ flask_security/templates/security/registrations/new.html | 2 ++ 7 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 flask_security/templates/security/messages.html diff --git a/flask_security/templates/security/confirmations/new.html b/flask_security/templates/security/confirmations/new.html index 36a1ca07..03e1e128 100644 --- a/flask_security/templates/security/confirmations/new.html +++ b/flask_security/templates/security/confirmations/new.html @@ -1 +1,7 @@ -Resend confirmation instructions... \ No newline at end of file +{% include "../messages.html" %} +

    Resend confirmation instructions

    +
    + {{ reset_confirmation_form.hidden_tag() }} + {{ reset_confirmation_form.email.label }} {{ reset_confirmation_form.email }} + {{ reset_confirmation_form.submit }} +
    \ No newline at end of file diff --git a/flask_security/templates/security/logins/new.html b/flask_security/templates/security/logins/new.html index 28d979c2..b3871f74 100644 --- a/flask_security/templates/security/logins/new.html +++ b/flask_security/templates/security/logins/new.html @@ -1,3 +1,5 @@ +{% include "../messages.html" %} +

    Login

    {{ form.hidden_tag() }} {{ form.email.label }} {{ form.email }}
    diff --git a/flask_security/templates/security/messages.html b/flask_security/templates/security/messages.html new file mode 100644 index 00000000..788a4134 --- /dev/null +++ b/flask_security/templates/security/messages.html @@ -0,0 +1,9 @@ +{%- with messages = get_flashed_messages(with_categories=true) -%} + {% if messages %} +
      + {% for category, message in messages %} +
    • {{ message }}
    • + {% endfor %} +
    + {% endif %} +{%- endwith %} \ No newline at end of file diff --git a/flask_security/templates/security/passwords/edit.html b/flask_security/templates/security/passwords/edit.html index 0c3ea67d..a0a3ec93 100644 --- a/flask_security/templates/security/passwords/edit.html +++ b/flask_security/templates/security/passwords/edit.html @@ -1,3 +1,5 @@ +{% include "../messages.html" %} +

    Change password

    {{ reset_password_form.hidden_tag() }} {{ reset_password_form.password.label }} {{ reset_password_form.password }}
    diff --git a/flask_security/templates/security/passwords/new.html b/flask_security/templates/security/passwords/new.html index c8f6a17d..af174f32 100644 --- a/flask_security/templates/security/passwords/new.html +++ b/flask_security/templates/security/passwords/new.html @@ -1,3 +1,5 @@ +{% include "../messages.html" %} +

    Send reset password instructions

    {{ forgot_password_form.hidden_tag() }} {{ forgot_password_form.email.label }} {{ forgot_password_form.email }} diff --git a/flask_security/templates/security/registrations/edit.html b/flask_security/templates/security/registrations/edit.html index a37845ea..5c2f24bf 100644 --- a/flask_security/templates/security/registrations/edit.html +++ b/flask_security/templates/security/registrations/edit.html @@ -1,3 +1,5 @@ +{% include "../messages.html" %} +

    Edit account

    {{ edit_user_form.hidden_tag() }} {{ edit_user_form.email.label }} {{ edit_user_form.email }}
    diff --git a/flask_security/templates/security/registrations/new.html b/flask_security/templates/security/registrations/new.html index c9d7bf7a..dc0fdd83 100644 --- a/flask_security/templates/security/registrations/new.html +++ b/flask_security/templates/security/registrations/new.html @@ -1,3 +1,5 @@ +{% include "../messages.html" %} +

    Register

    {{ register_user_form.hidden_tag() }} {{ register_user_form.email.label }} {{ register_user_form.email }}
    From 4d5fa0571100b6b05be42d0bbf217e223f054dcc Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 10 Jul 2012 15:39:10 -0400 Subject: [PATCH 063/234] Remove flask-principal session storage in favor of useing flask-login current_user since it does session persistence for us --- flask_security/core.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index cc451f25..5509efcf 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -14,7 +14,8 @@ from flask import current_app, Blueprint from flask.ext.login import AnonymousUser as AnonymousUserBase, \ UserMixin as BaseUserMixin, LoginManager, current_user -from flask.ext.principal import Principal, RoleNeed, UserNeed, identity_loaded +from flask.ext.principal import Principal, RoleNeed, UserNeed, Identity, \ + identity_loaded from passlib.context import CryptContext from werkzeug.datastructures import ImmutableList @@ -123,6 +124,12 @@ def _token_loader(token): return None +def _identity_loader(): + if not isinstance(current_user._get_current_object(), AnonymousUser): + identity = Identity(current_user.id) + return identity + + def _on_identity_loaded(sender, identity): if hasattr(current_user, 'id'): identity.provides.add(UserNeed(current_user.id)) @@ -172,7 +179,8 @@ def init_app(self, app, datastore): self.login_manager = login_manager self.pwd_context = CryptContext(schemes=[pw_hash], default=pw_hash) self.auth_provider = Provider() - self.principal = Principal(app) + self.principal = Principal(app, use_sessions=False) + self.principal.identity_loader(_identity_loader) self.datastore = datastore self.LoginForm = utils.get_class_from_string(app, 'LOGIN_FORM') self.RegisterForm = utils.get_class_from_string(app, 'REGISTER_FORM') From ab97e736d9d6fd689ffde63f2ee70385bde23f67 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 10 Jul 2012 15:57:16 -0400 Subject: [PATCH 064/234] Remove forms from config, pretty sure there's a better way to do this or its not necessary --- flask_security/core.py | 15 +++++++-------- flask_security/views.py | 10 ++++++---- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 5509efcf..922ed12c 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -31,10 +31,6 @@ 'FLASH_MESSAGES': True, 'PASSWORD_HASH': 'plaintext', 'AUTH_PROVIDER': 'flask.ext.security::AuthenticationProvider', - 'LOGIN_FORM': 'flask.ext.security.forms::LoginForm', - 'REGISTER_FORM': 'flask.ext.security.forms::RegisterForm', - 'RESET_PASSWORD_FORM': 'flask.ext.security.forms::ResetPasswordForm', - 'FORGOT_PASSWORD_FORM': 'flask.ext.security.forms::ForgotPasswordForm', 'AUTH_URL': '/auth', 'LOGOUT_URL': '/logout', 'REGISTER_URL': '/register', @@ -177,27 +173,30 @@ def init_app(self, app, datastore): pw_hash = utils.config_value(app, 'PASSWORD_HASH') self.login_manager = login_manager + self.pwd_context = CryptContext(schemes=[pw_hash], default=pw_hash) + self.auth_provider = Provider() + self.principal = Principal(app, use_sessions=False) self.principal.identity_loader(_identity_loader) + self.datastore = datastore - self.LoginForm = utils.get_class_from_string(app, 'LOGIN_FORM') - self.RegisterForm = utils.get_class_from_string(app, 'REGISTER_FORM') - self.ResetPasswordForm = utils.get_class_from_string(app, 'RESET_PASSWORD_FORM') - self.ForgotPasswordForm = utils.get_class_from_string(app, 'FORGOT_PASSWORD_FORM') + self.auth_url = utils.config_value(app, 'AUTH_URL') self.logout_url = utils.config_value(app, 'LOGOUT_URL') self.reset_url = utils.config_value(app, 'RESET_URL') self.register_url = utils.config_value(app, 'REGISTER_URL') self.confirm_url = utils.config_value(app, 'CONFIRM_URL') self.forgot_url = utils.config_value(app, 'FORGOT_URL') + self.post_login_view = utils.config_value(app, 'POST_LOGIN_VIEW') self.post_logout_view = utils.config_value(app, 'POST_LOGOUT_VIEW') self.post_register_view = utils.config_value(app, 'POST_REGISTER_VIEW') self.post_confirm_view = utils.config_value(app, 'POST_CONFIRM_VIEW') self.post_forgot_view = utils.config_value(app, 'POST_FORGOT_VIEW') self.reset_password_error_view = utils.config_value(app, 'RESET_PASSWORD_ERROR_VIEW') + self.default_roles = utils.config_value(app, "DEFAULT_ROLES") self.login_without_confirmation = utils.config_value(app, 'LOGIN_WITHOUT_CONFIRMATION') self.confirmable = utils.config_value(app, 'CONFIRMABLE') diff --git a/flask_security/views.py b/flask_security/views.py index 78056347..890b9801 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -21,6 +21,8 @@ reset_confirmation_token, send_confirmation_instructions from .exceptions import TokenExpiredError, UserNotFoundError, \ ConfirmationError, BadCredentialsError, ResetPasswordError +from .forms import LoginForm, RegisterForm, ForgotPasswordForm, \ + ResetPasswordForm from .recoverable import reset_by_token, \ reset_password_reset_token from .signals import user_registered @@ -67,7 +69,7 @@ def _do_login(user, remember=True): def authenticate(): """View function which handles an authentication request.""" - form = _security.LoginForm() + form = LoginForm() try: user = _security.auth_provider.authenticate(form) @@ -108,7 +110,7 @@ def logout(): def register(): """View function which handles a registration request.""" - form = _security.RegisterForm(csrf_enabled=not app.testing) + form = RegisterForm(csrf_enabled=not app.testing) # Exit early if the form doesn't validate if form.validate_on_submit(): @@ -165,7 +167,7 @@ def confirm(): def forgot(): """View function that handles a forgotten password request.""" - form = _security.ForgotPasswordForm(csrf_enabled=not app.testing) + form = ForgotPasswordForm(csrf_enabled=not app.testing) if form.validate_on_submit(): try: @@ -188,7 +190,7 @@ def forgot(): def reset(): """View function that handles a reset password request.""" - form = _security.ResetPasswordForm(csrf_enabled=not app.testing) + form = ResetPasswordForm(csrf_enabled=not app.testing) if form.validate_on_submit(): try: From 49d3789f984068d2c2a813507fd07cf6d92f3402 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 10 Jul 2012 16:23:20 -0400 Subject: [PATCH 065/234] Fix up remember token --- flask_security/core.py | 1 + flask_security/datastore.py | 5 ++++- flask_security/utils.py | 7 +++++++ flask_security/views.py | 8 ++++---- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 922ed12c..f3626b98 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -113,6 +113,7 @@ def _user_loader(user_id): def _token_loader(token): + print 'token loader!' try: return current_app.security.datastore.find_user(remember_token=token) except Exception, e: diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 90d214ac..4746102b 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -11,7 +11,7 @@ from flask import current_app -from . import exceptions, confirmable +from . import exceptions, confirmable, utils class UserDatastore(object): @@ -113,6 +113,9 @@ def _prepare_create_user_args(self, kwargs): if not pwd_context.identify(pw): kwargs['password'] = pwd_context.encrypt(pw) + kwargs['remember_token'] = utils.get_remember_token(kwargs['email'], + kwargs['password']) + return kwargs def with_id(self, id): diff --git a/flask_security/utils.py b/flask_security/utils.py index 77abb1ff..7d51ad5a 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -15,6 +15,7 @@ from importlib import import_module from flask import url_for, flash, current_app, request, session, render_template +from flask.ext.login import make_secure_token from .signals import user_registered, password_reset_requested @@ -24,6 +25,12 @@ def generate_token(): return base64.urlsafe_b64encode(os.urandom(30)) +def get_remember_token(email, password): + assert email is not None + assert password is not None + return make_secure_token(email, password) + + def do_flash(message, category=None): """Flash a message depending on if the `FLASH_MESSAGES` configuration value is set. diff --git a/flask_security/views.py b/flask_security/views.py index 890b9801..423be77a 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -13,7 +13,7 @@ from flask import current_app as app, redirect, request, session, \ render_template -from flask.ext.login import login_user, logout_user, make_secure_token +from flask.ext.login import login_user, logout_user from flask.ext.principal import Identity, AnonymousIdentity, identity_changed from werkzeug.local import LocalProxy @@ -26,7 +26,7 @@ from .recoverable import reset_by_token, \ reset_password_reset_token from .signals import user_registered -from .utils import get_post_login_redirect, do_flash +from .utils import get_post_login_redirect, do_flash, get_remember_token # Convenient references @@ -43,8 +43,8 @@ def _do_login(user, remember=True): if not login_user(user, remember): return False - user.remember_token = None if not remember else \ - make_secure_token(user.email, user.password) + if remember: + user.remember_token = get_remember_token(user.email, user.password) if _security.trackable: old_current, new_current = user.current_login_at, datetime.utcnow() From 00860c29d0355792a5c422235af5f6c0f6fd2fc5 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 10 Jul 2012 16:23:59 -0400 Subject: [PATCH 066/234] Remove print statement --- flask_security/core.py | 1 - 1 file changed, 1 deletion(-) diff --git a/flask_security/core.py b/flask_security/core.py index f3626b98..922ed12c 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -113,7 +113,6 @@ def _user_loader(user_id): def _token_loader(token): - print 'token loader!' try: return current_app.security.datastore.find_user(remember_token=token) except Exception, e: From 815c8695fc07be0f16a23f7d30c016089af826f6 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 10 Jul 2012 17:24:39 -0400 Subject: [PATCH 067/234] Code clean upr --- flask_security/confirmable.py | 4 +- flask_security/core.py | 151 ++++++++++++++-------------------- flask_security/recoverable.py | 4 +- flask_security/utils.py | 58 +++++++++---- flask_security/views.py | 2 +- tests/functional_tests.py | 2 +- 6 files changed, 109 insertions(+), 112 deletions(-) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index a160d215..7d872208 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -15,7 +15,7 @@ from werkzeug.local import LocalProxy from .exceptions import UserNotFoundError, ConfirmationError, TokenExpiredError -from .utils import generate_token, send_mail +from .utils import generate_token, send_mail, get_within_delta from .signals import user_confirmed, confirm_instructions_sent @@ -96,7 +96,7 @@ def requires_confirmation(user): @should_confirm_email def confirmation_token_is_expired(user): """Returns `True` if the user's confirmation token is expired.""" - token_expires = datetime.utcnow() - _security.confirm_email_within + token_expires = datetime.utcnow() - get_within_delta('CONFIRM_EMAIL_WITHIN') return user.confirmation_sent_at < token_expires diff --git a/flask_security/core.py b/flask_security/core.py index 922ed12c..0d5ef20b 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -9,8 +9,6 @@ :license: MIT, see LICENSE for more details. """ -from datetime import timedelta - from flask import current_app, Blueprint from flask.ext.login import AnonymousUser as AnonymousUserBase, \ UserMixin as BaseUserMixin, LoginManager, current_user @@ -19,10 +17,11 @@ from passlib.context import CryptContext from werkzeug.datastructures import ImmutableList -from . import views, exceptions, utils +from . import views, exceptions from .confirmable import confirmation_token_is_expired, requires_confirmation, \ reset_confirmation_token from .decorators import login_required +from .utils import config_value as cv, get_config #: Default Flask-Security configuration @@ -30,7 +29,6 @@ 'URL_PREFIX': None, 'FLASH_MESSAGES': True, 'PASSWORD_HASH': 'plaintext', - 'AUTH_PROVIDER': 'flask.ext.security::AuthenticationProvider', 'AUTH_URL': '/auth', 'LOGOUT_URL': '/logout', 'REGISTER_URL': '/register', @@ -136,6 +134,57 @@ def _on_identity_loaded(sender, identity): identity.user = current_user +def _get_login_manager(app): + lm = LoginManager() + lm.anonymous_user = AnonymousUser + lm.login_view = cv('LOGIN_VIEW', app=app) + lm.user_loader(_user_loader) + lm.token_loader(_token_loader) + lm.init_app(app) + return lm + + +def _get_principal(app): + p = Principal(app, use_sessions=False) + p.identity_loader(_identity_loader) + return p + + +def _get_pwd_context(app): + pw_hash = cv('PASSWORD_HASH', app=app) + return CryptContext(schemes=[pw_hash], default=pw_hash) + + +def _create_blueprint(config): + bp = Blueprint('flask_security', __name__, template_folder='templates') + + bp.route(config['SECURITY_AUTH_URL'], + methods=['POST'], + endpoint='authenticate')(views.authenticate) + + bp.route(config['SECURITY_LOGOUT_URL'], + endpoint='logout')(login_required(views.logout)) + + if config['SECURITY_REGISTERABLE']: + bp.route(config['SECURITY_REGISTER_URL'], + methods=['POST'], + endpoint='register')(views.register) + + if config['SECURITY_RECOVERABLE']: + bp.route(config['SECURITY_FORGOT_URL'], + methods=['POST'], + endpoint='forgot')(views.forgot) + bp.route(config['SECURITY_RESET_URL'], + methods=['POST'], + endpoint='reset')(views.reset) + + if config['SECURITY_CONFIRMABLE']: + bp.route(config['SECURITY_CONFIRM_URL'], + endpoint='confirm')(views.confirm) + + return bp + + class Security(object): """The :class:`Security` class initializes the Flask-Security extension. @@ -151,10 +200,6 @@ def init_app(self, app, datastore): :param app: The application. :param datastore: An instance of a user datastore. - :param confirmable: Set to `True` to enable email confirmation - :param registerable: Set to `False` to disable registration endpoints - :param recoverable: Set to `False` to disable password recovery - endpoints """ if app is None or datastore is None: return @@ -162,96 +207,22 @@ def init_app(self, app, datastore): for key, value in _default_config.items(): app.config.setdefault('SECURITY_' + key, value) - login_manager = LoginManager() - login_manager.anonymous_user = AnonymousUser - login_manager.login_view = utils.config_value(app, 'LOGIN_VIEW') - login_manager.user_loader(_user_loader) - login_manager.token_loader(_token_loader) - login_manager.init_app(app) - - Provider = utils.get_class_from_string(app, 'AUTH_PROVIDER') - pw_hash = utils.config_value(app, 'PASSWORD_HASH') - - self.login_manager = login_manager - - self.pwd_context = CryptContext(schemes=[pw_hash], default=pw_hash) - - self.auth_provider = Provider() - - self.principal = Principal(app, use_sessions=False) - self.principal.identity_loader(_identity_loader) - self.datastore = datastore + self.auth_provider = AuthenticationProvider() + self.login_manager = _get_login_manager(app) + self.principal = _get_principal(app) + self.pwd_context = _get_pwd_context(app) - self.auth_url = utils.config_value(app, 'AUTH_URL') - self.logout_url = utils.config_value(app, 'LOGOUT_URL') - self.reset_url = utils.config_value(app, 'RESET_URL') - self.register_url = utils.config_value(app, 'REGISTER_URL') - self.confirm_url = utils.config_value(app, 'CONFIRM_URL') - self.forgot_url = utils.config_value(app, 'FORGOT_URL') - - self.post_login_view = utils.config_value(app, 'POST_LOGIN_VIEW') - self.post_logout_view = utils.config_value(app, 'POST_LOGOUT_VIEW') - self.post_register_view = utils.config_value(app, 'POST_REGISTER_VIEW') - self.post_confirm_view = utils.config_value(app, 'POST_CONFIRM_VIEW') - self.post_forgot_view = utils.config_value(app, 'POST_FORGOT_VIEW') - self.reset_password_error_view = utils.config_value(app, 'RESET_PASSWORD_ERROR_VIEW') - - self.default_roles = utils.config_value(app, "DEFAULT_ROLES") - self.login_without_confirmation = utils.config_value(app, 'LOGIN_WITHOUT_CONFIRMATION') - self.confirmable = utils.config_value(app, 'CONFIRMABLE') - self.registerable = utils.config_value(app, 'REGISTERABLE') - self.recoverable = utils.config_value(app, 'RECOVERABLE') - self.trackable = utils.config_value(app, 'TRACKABLE') - self.email_sender = utils.config_value(app, 'EMAIL_SENDER') - self.token_authentication_key = utils.config_value(app, 'TOKEN_AUTHENTICATION_KEY') - self.token_authentication_header = utils.config_value(app, 'TOKEN_AUTHENTICATION_HEADER') - - self.confirm_email_within_text = utils.config_value(app, 'CONFIRM_EMAIL_WITHIN') - values = self.confirm_email_within_text.split() - self.confirm_email_within = timedelta(**{values[1]: int(values[0])}) - - self.reset_password_within_text = utils.config_value(app, 'RESET_PASSWORD_WITHIN') - values = self.reset_password_within_text.split() - self.reset_password_within = timedelta(**{values[1]: int(values[0])}) + for key, value in get_config(app).items(): + setattr(self, key.lower(), value) identity_loaded.connect_via(app)(_on_identity_loaded) - bp = Blueprint('flask_security', __name__, template_folder='templates') - - bp.route(self.auth_url, - methods=['POST'], - endpoint='authenticate')(views.authenticate) - - bp.route(self.logout_url, - endpoint='logout')(login_required(views.logout)) - - self._setup_registerable(bp) if self.registerable else None - self._setup_recoverable(bp) if self.recoverable else None - self._setup_confirmable(bp) if self.confirmable else None - - app.register_blueprint(bp, - url_prefix=utils.config_value(app, 'URL_PREFIX')) + app.register_blueprint(_create_blueprint(app.config), + url_prefix=cv('URL_PREFIX', app=app)) app.security = self - def _setup_registerable(self, bp): - bp.route(self.register_url, - methods=['POST'], - endpoint='register')(views.register) - - def _setup_recoverable(self, bp): - bp.route(self.forgot_url, - methods=['POST'], - endpoint='forgot')(views.forgot) - bp.route(self.reset_url, - methods=['POST'], - endpoint='reset')(views.reset) - - def _setup_confirmable(self, bp): - bp.route(self.confirm_url, - endpoint='confirm')(views.confirm) - class AuthenticationProvider(object): """The default authentication provider implementation.""" diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 617cce9a..3fb319d7 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -18,7 +18,7 @@ TokenExpiredError from .signals import password_reset, password_reset_requested, \ confirm_instructions_sent -from .utils import generate_token, send_mail +from .utils import generate_token, send_mail, get_within_delta # Convenient references @@ -87,7 +87,7 @@ def password_reset_token_is_expired(user): :param user: The user to examine """ - token_expires = datetime.utcnow() - _security.reset_password_within + token_expires = datetime.utcnow() - get_within_delta('RESET_PASSWORD_WITHIN') return user.reset_password_sent_at < token_expires diff --git a/flask_security/utils.py b/flask_security/utils.py index 7d51ad5a..e2902909 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -12,7 +12,7 @@ import base64 import os from contextlib import contextmanager -from importlib import import_module +from datetime import timedelta from flask import url_for, flash, current_app, request, session, render_template from flask.ext.login import make_secure_token @@ -38,21 +38,10 @@ def do_flash(message, category=None): :param message: The flash message :param category: The flash message category """ - if config_value(current_app, 'FLASH_MESSAGES'): + if config_value('FLASH_MESSAGES'): flash(message, category) -def get_class_from_string(app, key): - """Get a reference to a class by its configuration key name. - - :param app: The application instance to work against - :param key: The configuration key to use - """ - cv = config_value(app, key).split('::') - cm = import_module(cv[0]) - return getattr(cm, cv[1]) - - def get_url(endpoint_or_url): """Returns a URL if a valid endpoint is found. Otherwise, returns the provided value. @@ -85,14 +74,51 @@ def find_redirect(key): return result -def config_value(app, key, default=None): +def get_config(app): + """Conveniently get the security configuration for the specified + application without the annoying 'SECURITY_' prefix. + + :param app: The application to inspect + """ + items = app.config.items() + prefix = 'SECURITY_' + + def strip_prefix(tup): + return (tup[0].replace('SECURITY_', ''), tup[1]) + + return dict([strip_prefix(i) for i in items if i[0].startswith(prefix)]) + + +def config_value(key, app=None, default=None): """Get a Flask-Security configuration value. - :param app: The application to retrieve the configuration from :param key: The configuration key without the prefix `SECURITY_` + :param app: An optional specific application to inspect. Defaults to Flask's + `current_app` :param default: An optional default value if the value is not set """ - return app.config.get('SECURITY_' + key.upper(), default) + app = app or current_app + return get_config(app).get(key.upper(), default) + + +def get_within_delta(key, app=None): + """Get a timedelta object from the application configuration following + the internal convention of:: + + + + Examples of valid config values:: + + 5 days + 10 minutes + + :param key: The config value key without the 'SECURITY_' prefix + :param app: Optional application to inspect. Defaults to Flask's + `current_app` + """ + txt = config_value(key, app=app) + values = txt.split() + return timedelta(**{values[1]: int(values[0])}) def send_mail(subject, recipient, template, context=None): diff --git a/flask_security/views.py b/flask_security/views.py index 423be77a..9e60b930 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -152,7 +152,7 @@ def confirm(): msg = 'You did not confirm your email within %s. ' \ 'A new confirmation code has been sent to %s' % ( - _security.confirm_email_within_text, e.user.email) + _security.confirm_email_within, e.user.email) do_flash(msg, 'error') return redirect('/') # TODO: Don't redirect to root diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 62725b48..2817ea37 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -213,7 +213,7 @@ def test_expired_confirmation_token_sends_email(self): self.assertIn(e, outbox[0].html) self.assertNotIn(token, outbox[0].html) - expire_text = self.app.security.confirm_email_within_text + expire_text = self.app.security.confirm_email_within text = 'You did not confirm your email within %s' % expire_text self.assertIn(text, r.data) From 338a9cd71703b3a5cce1cd388fe301adc3f53037 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 10 Jul 2012 17:28:14 -0400 Subject: [PATCH 068/234] Use same convention as other methods --- flask_security/core.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 0d5ef20b..925e8c3f 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -155,31 +155,31 @@ def _get_pwd_context(app): return CryptContext(schemes=[pw_hash], default=pw_hash) -def _create_blueprint(config): +def _create_blueprint(app): bp = Blueprint('flask_security', __name__, template_folder='templates') - bp.route(config['SECURITY_AUTH_URL'], + bp.route(cv('AUTH_URL', app=app), methods=['POST'], endpoint='authenticate')(views.authenticate) - bp.route(config['SECURITY_LOGOUT_URL'], + bp.route(cv('LOGOUT_URL', app=app), endpoint='logout')(login_required(views.logout)) - if config['SECURITY_REGISTERABLE']: - bp.route(config['SECURITY_REGISTER_URL'], + if cv('REGISTERABLE', app=app): + bp.route(cv('REGISTER_URL', app=app), methods=['POST'], endpoint='register')(views.register) - if config['SECURITY_RECOVERABLE']: - bp.route(config['SECURITY_FORGOT_URL'], + if cv('RECOVERABLE', app=app): + bp.route(cv('FORGOT_URL', app=app), methods=['POST'], endpoint='forgot')(views.forgot) - bp.route(config['SECURITY_RESET_URL'], + bp.route(cv('RESET_URL', app=app), methods=['POST'], endpoint='reset')(views.reset) - if config['SECURITY_CONFIRMABLE']: - bp.route(config['SECURITY_CONFIRM_URL'], + if cv('CONFIRMABLE', app=app): + bp.route(cv('CONFIRM_URL', app=app), endpoint='confirm')(views.confirm) return bp @@ -218,7 +218,7 @@ def init_app(self, app, datastore): identity_loaded.connect_via(app)(_on_identity_loaded) - app.register_blueprint(_create_blueprint(app.config), + app.register_blueprint(_create_blueprint(app), url_prefix=cv('URL_PREFIX', app=app)) app.security = self From 703487e608ee0a5daef8793dbbe242868bac57f3 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 10 Jul 2012 17:29:43 -0400 Subject: [PATCH 069/234] Move things around for aesthetic reasons --- flask_security/core.py | 92 +++++++++++++++++++++--------------------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 925e8c3f..603a91a9 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -56,52 +56,6 @@ } -class RoleMixin(object): - """Mixin for `Role` model definitions""" - def __eq__(self, other): - return self.name == other or self.name == getattr(other, 'name', None) - - def __ne__(self, other): - return self.name != other and self.name != getattr(other, 'name', None) - - def __str__(self): - return '' % self.name - - -class UserMixin(BaseUserMixin): - """Mixin for `User` model definitions""" - - def is_active(self): - """Returns `True` if the user is active.""" - return self.active - - def get_auth_token(self): - """Returns the user's authentication token.""" - self.remember_token - - def has_role(self, role): - """Returns `True` if the user identifies with the specified role. - - :param role: A role name or `Role` instance""" - return role in self.roles - - def __str__(self): - ctx = (str(self.id), self.email) - return '' % ctx - - -class AnonymousUser(AnonymousUserBase): - """AnonymousUser definition""" - - def __init__(self): - super(AnonymousUser, self).__init__() - self.roles = ImmutableList() - - def has_role(self, *args): - """Returns `False`""" - return False - - def _user_loader(user_id): try: return current_app.security.datastore.with_id(user_id) @@ -185,6 +139,52 @@ def _create_blueprint(app): return bp +class RoleMixin(object): + """Mixin for `Role` model definitions""" + def __eq__(self, other): + return self.name == other or self.name == getattr(other, 'name', None) + + def __ne__(self, other): + return self.name != other and self.name != getattr(other, 'name', None) + + def __str__(self): + return '' % self.name + + +class UserMixin(BaseUserMixin): + """Mixin for `User` model definitions""" + + def is_active(self): + """Returns `True` if the user is active.""" + return self.active + + def get_auth_token(self): + """Returns the user's authentication token.""" + self.remember_token + + def has_role(self, role): + """Returns `True` if the user identifies with the specified role. + + :param role: A role name or `Role` instance""" + return role in self.roles + + def __str__(self): + ctx = (str(self.id), self.email) + return '' % ctx + + +class AnonymousUser(AnonymousUserBase): + """AnonymousUser definition""" + + def __init__(self): + super(AnonymousUser, self).__init__() + self.roles = ImmutableList() + + def has_role(self, *args): + """Returns `False`""" + return False + + class Security(object): """The :class:`Security` class initializes the Flask-Security extension. From b9a6a9c5a8679051cd627a7a5d023e19b7586090 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 11 Jul 2012 15:06:54 -0400 Subject: [PATCH 070/234] Use itsdangerous for activation and password reset tokens so they do not need to be stored in the database --- example/app.py | 10 ---- flask_security/confirmable.py | 71 ++++++++++++-------------- flask_security/core.py | 38 ++++++++++---- flask_security/datastore.py | 3 -- flask_security/forms.py | 14 +---- flask_security/recoverable.py | 96 ++++++++++++++++------------------- flask_security/utils.py | 39 +++++++------- flask_security/views.py | 20 ++++---- tests/functional_tests.py | 83 ++++++++++++++++-------------- 9 files changed, 184 insertions(+), 190 deletions(-) diff --git a/example/app.py b/example/app.py index 3ec090a7..bbe1b4a2 100644 --- a/example/app.py +++ b/example/app.py @@ -136,13 +136,8 @@ class User(db.Model, UserMixin): current_login_ip = db.Column(db.String(100)) login_count = db.Column(db.Integer) active = db.Column(db.Boolean()) - confirmation_token = db.Column(db.String(255)) - confirmation_sent_at = db.Column(db.DateTime()) confirmed_at = db.Column(db.DateTime()) - reset_password_token = db.Column(db.String(255)) - reset_password_sent_at = db.Column(db.DateTime()) authentication_token = db.Column(db.String(255)) - authentication_token_created_at = db.Column(db.DateTime()) roles = db.relationship('Role', secondary=roles_users, backref=db.backref('users', lazy='dynamic')) @@ -179,13 +174,8 @@ class User(db.Document, UserMixin): current_login_ip = db.StringField(max_length=100) login_count = db.IntField() active = db.BooleanField(default=True) - confirmation_token = db.StringField(max_length=255) - confirmation_sent_at = db.DateTimeField() confirmed_at = db.DateTimeField() - reset_password_token = db.StringField(max_length=255) - reset_password_sent_at = db.DateTimeField() authentication_token = db.StringField(max_length=255) - authentication_token_created_at = db.DateTimeField() roles = db.ListField(db.ReferenceField(Role), default=[]) Security(app, MongoEngineUserDatastore(db, User, Role)) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 7d872208..aadb5511 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -11,11 +11,12 @@ from datetime import datetime +from itsdangerous import BadSignature, SignatureExpired from flask import current_app as app, request, url_for from werkzeug.local import LocalProxy from .exceptions import UserNotFoundError, ConfirmationError, TokenExpiredError -from .utils import generate_token, send_mail, get_within_delta +from .utils import send_mail, get_max_age, md5 from .signals import user_confirmed, confirm_instructions_sent @@ -35,13 +36,13 @@ def find_user_by_confirmation_token(token): return _datastore.find_user(confirmation_token=token) -def send_confirmation_instructions(user): +def send_confirmation_instructions(user, token): """Sends the confirmation instructions email for the specified user. :param user: The user to send the instructions to """ url = url_for('flask_security.confirm', - confirmation_token=user.confirmation_token) + token=token) confirmation_link = request.url_root[:-1] + url @@ -59,23 +60,8 @@ def generate_confirmation_token(user): :param user: The user to work with """ - while True: - token = generate_token() - try: - find_user_by_confirmation_token(token) - except UserNotFoundError: - break - - now = datetime.utcnow() - - try: - user['confirmation_token'] = token - user['confirmation_sent_at'] = now - except TypeError: - user.confirmation_token = token - user.confirmation_sent_at = now - - return user + data = [user.id, md5(user.email)] + return _security.confirm_serializer.dumps(data) def should_confirm_email(fn): @@ -93,13 +79,6 @@ def requires_confirmation(user): return user.confirmed_at == None -@should_confirm_email -def confirmation_token_is_expired(user): - """Returns `True` if the user's confirmation token is expired.""" - token_expires = datetime.utcnow() - get_within_delta('CONFIRM_EMAIL_WITHIN') - return user.confirmation_sent_at < token_expires - - def confirm_by_token(token): """Confirm the user given the specified token. If the token is invalid or the user is already confirmed a `ConfirmationError` error will be raised. @@ -107,26 +86,36 @@ def confirm_by_token(token): :param token: The user's confirmation token """ + serializer = _security.confirm_serializer + max_age = get_max_age('CONFIRM_EMAIL') + try: - user = find_user_by_confirmation_token(token) + data = serializer.loads(token, max_age=max_age) + user = _datastore.find_user(id=data[0]) + + if md5(user.email) != data[1]: + raise UserNotFoundError() + except UserNotFoundError: raise ConfirmationError('Invalid confirmation token') - if user.confirmed_at: - raise ConfirmationError('Account has already been confirmed') - - if confirmation_token_is_expired(user): + except SignatureExpired: + sig_okay, data = serializer.loads_unsafe(token) + user = _datastore.find_user(id=data[0]) raise TokenExpiredError(message='Confirmation token is expired', user=user) - # TODO: Clear confirmation_token after confirmation? - #user.confirmation_token = None - #user.confirmation_sent_at = None - user.confirmed_at = datetime.utcnow() + except BadSignature: + raise ConfirmationError('Invalid confirmation token') + + if user.confirmed_at: + raise ConfirmationError('Account has already been confirmed') + user.confirmed_at = datetime.utcnow() _datastore._save_model(user) user_confirmed.send(user, app=app._get_current_object()) + return user @@ -136,5 +125,11 @@ def reset_confirmation_token(user): :param user: The user to work with """ - _datastore._save_model(generate_confirmation_token(user)) - send_confirmation_instructions(user) + token = generate_confirmation_token(user) + + user.confirmed_at = None + _datastore._save_model(user) + + send_confirmation_instructions(user, token) + + return token diff --git a/flask_security/core.py b/flask_security/core.py index 603a91a9..9044bc37 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -9,6 +9,7 @@ :license: MIT, see LICENSE for more details. """ +from itsdangerous import URLSafeTimedSerializer from flask import current_app, Blueprint from flask.ext.login import AnonymousUser as AnonymousUserBase, \ UserMixin as BaseUserMixin, LoginManager, current_user @@ -18,8 +19,7 @@ from werkzeug.datastructures import ImmutableList from . import views, exceptions -from .confirmable import confirmation_token_is_expired, requires_confirmation, \ - reset_confirmation_token +from .confirmable import requires_confirmation, reset_confirmation_token from .decorators import login_required from .utils import config_value as cv, get_config @@ -33,8 +33,8 @@ 'LOGOUT_URL': '/logout', 'REGISTER_URL': '/register', 'FORGOT_URL': '/forgot', - 'RESET_URL': '/reset', - 'CONFIRM_URL': '/confirm', + 'RESET_URL': '/reset/', + 'CONFIRM_URL': '/confirm/', 'LOGIN_VIEW': '/login', 'POST_LOGIN_VIEW': '/', 'POST_LOGOUT_VIEW': '/', @@ -48,11 +48,14 @@ 'RECOVERABLE': False, 'TRACKABLE': False, 'CONFIRM_EMAIL_WITHIN': '5 days', - 'RESET_PASSWORD_WITHIN': '2 days', + 'RESET_PASSWORD_WITHIN': '5 days', 'LOGIN_WITHOUT_CONFIRMATION': False, 'EMAIL_SENDER': 'no-reply@localhost', 'TOKEN_AUTHENTICATION_KEY': 'auth_token', - 'TOKEN_AUTHENTICATION_HEADER': 'X-Auth-Token' + 'TOKEN_AUTHENTICATION_HEADER': 'X-Auth-Token', + 'CONFIRM_SALT': 'confirm-salt', + 'RESET_SALT': 'reset-salt', + 'AUTH_SALT': 'auth-salt' } @@ -109,6 +112,23 @@ def _get_pwd_context(app): return CryptContext(schemes=[pw_hash], default=pw_hash) +def _get_serializer(app, salt): + secret_key = app.config.get('SECRET_KEY', 'secret-key') + return URLSafeTimedSerializer(secret_key=secret_key, salt=salt) + + +def _get_reset_serializer(app): + return _get_serializer(app, app.config['SECURITY_RESET_SALT']) + + +def _get_confirm_serializer(app): + return _get_serializer(app, app.config['SECURITY_CONFIRM_SALT']) + + +def _get_token_auth_serializer(app): + return _get_serializer(app, app.config['SECURITY_AUTH_SALT']) + + def _create_blueprint(app): bp = Blueprint('flask_security', __name__, template_folder='templates') @@ -212,6 +232,9 @@ def init_app(self, app, datastore): self.login_manager = _get_login_manager(app) self.principal = _get_principal(app) self.pwd_context = _get_pwd_context(app) + self.reset_serializer = _get_reset_serializer(app) + self.confirm_serializer = _get_confirm_serializer(app) + self.token_auth_serializer = _get_token_auth_serializer(app) for key, value in get_config(app).items(): setattr(self, key.lower(), value) @@ -269,9 +292,6 @@ def do_authenticate(self, username_or_email, password): except Exception, e: self.auth_error('Unexpected authentication error: %s' % e) - if confirmation_token_is_expired(user): - reset_confirmation_token(user) - if requires_confirmation(user): raise exceptions.BadCredentialsError('Account requires confirmation') diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 4746102b..3ba44475 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -90,9 +90,6 @@ def _prepare_create_user_args(self, kwargs): kwargs.setdefault('active', True) kwargs.setdefault('roles', current_app.security.default_roles) - if current_app.security.confirmable: - confirmable.generate_confirmation_token(kwargs) - if email is None: raise exceptions.UserCreationError('Missing email argument') diff --git a/flask_security/forms.py b/flask_security/forms.py index 1b780c74..b8cd0944 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -66,23 +66,11 @@ def to_dict(self): class ResetPasswordForm(Form, - EmailFormMixin, PasswordFormMixin, PasswordConfirmFormMixin): """The default reset password form""" - token = HiddenField(validators=[Required()]) - submit = SubmitField("Reset Password") - def __init__(self, *args, **kwargs): - super(ResetPasswordForm, self).__init__(*args, **kwargs) - - if request.method == 'GET': - self.token.data = request.args.get('token', None) - self.email.data = request.args.get('email', None) - def to_dict(self): - return dict(token=self.token.data, - email=self.email.data, - password=self.password.data) + return dict(password=self.password.data) diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 3fb319d7..7fb6fbe8 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -9,16 +9,15 @@ :license: MIT, see LICENSE for more details. """ -from datetime import datetime - +from itsdangerous import BadSignature, SignatureExpired from flask import current_app as app, request, url_for from werkzeug.local import LocalProxy from .exceptions import ResetPasswordError, UserNotFoundError, \ TokenExpiredError from .signals import password_reset, password_reset_requested, \ - confirm_instructions_sent -from .utils import generate_token, send_mail, get_within_delta + reset_instructions_sent +from .utils import send_mail, get_max_age, md5 # Convenient references @@ -27,24 +26,13 @@ _datastore = LocalProxy(lambda: app.security.datastore) -def find_user_by_reset_token(token): - """Returns a user with a matching reset password token. - - :param token: The reset password token - """ - if not token: - raise ResetPasswordError('Reset password token required') - return _datastore.find_user(reset_password_token=token) - - -def send_reset_password_instructions(user): +def send_reset_password_instructions(user, reset_token): """Sends the reset password instructions email for the specified user. :param user: The user to send the instructions to """ url = url_for('flask_security.reset', - email=user.email, - reset_token=user.reset_password_token) + token=reset_token) reset_link = request.url_root[:-1] + url @@ -53,45 +41,33 @@ def send_reset_password_instructions(user): 'reset_instructions', dict(user=user, reset_link=reset_link)) - confirm_instructions_sent.send(user, app=app._get_current_object()) + reset_instructions_sent.send(dict(user=user, token=reset_token), + app=app._get_current_object()) return True -def generate_reset_password_token(user): - """Generates a unique reset password token for the specified user. +def send_password_reset_notice(user): + """Sends the password reset notice email for the specified user. - :param user: The user to work with + :param user: The user to send the notice to """ - while True: - token = generate_token() - try: - find_user_by_reset_token(token) - except UserNotFoundError: - break - - now = datetime.utcnow() - - try: - user['reset_password_token'] = token - user['reset_password_sent_at'] = now - except TypeError: - user.reset_password_token = token - user.reset_password_sent_at = now - - return user + send_mail('Your password has been reset', + user.email, + 'reset_notice', + dict(user=user)) -def password_reset_token_is_expired(user): - """Returns `True` if the specified user's reset password token is expired. +def generate_reset_password_token(user): + """Generates a unique reset password token for the specified user. - :param user: The user to examine + :param user: The user to work with """ - token_expires = datetime.utcnow() - get_within_delta('RESET_PASSWORD_WITHIN') - return user.reset_password_sent_at < token_expires + data = [user.id, md5(user.password)] + return _security.reset_serializer.dumps(data) -def reset_by_token(token, email, password): +def reset_by_token(token, password): """Resets the password of the user given the specified token, email and password. If the token is invalid a `ResetPasswordError` error will be raised. If the token is expired a `TokenExpiredError` error will be raised. @@ -100,21 +76,32 @@ def reset_by_token(token, email, password): :param email: The user's email address :param password: The user's new password """ + serializer = _security.reset_serializer + max_age = get_max_age('RESET_PASSWORD') + try: - user = find_user_by_reset_token(token) + data = serializer.loads(token, max_age=max_age) + user = _datastore.find_user(id=data[0]) + + if md5(user.password) != data[1]: + raise UserNotFoundError() + except UserNotFoundError: raise ResetPasswordError('Invalid reset password token') - if password_reset_token_is_expired(user): + except SignatureExpired: + sig_okay, data = serializer.loads_unsafe(token) + user = _datastore.find_user(id=data[0]) raise TokenExpiredError('Reset password token is expired', user) - user.reset_password_token = None - user.reset_password_sent_at = None + except BadSignature: + raise ResetPasswordError('Invalid reset password token') + user.password = _security.pwd_context.encrypt(password) _datastore._save_model(user) - send_mail('Your password has been reset', user.email, 'reset_notice') + send_password_reset_notice(user) password_reset.send(user, app=app._get_current_object()) @@ -127,6 +114,11 @@ def reset_password_reset_token(user): :param user: The user to work with """ - _datastore._save_model(generate_reset_password_token(user)) - send_reset_password_instructions(user) - password_reset_requested.send(user, app=app._get_current_object()) + token = generate_reset_password_token(user) + + send_reset_password_instructions(user, token) + + password_reset_requested.send(dict(user=user, token=token), + app=app._get_current_object()) + + return token diff --git a/flask_security/utils.py b/flask_security/utils.py index e2902909..3e43e74f 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -10,9 +10,10 @@ """ import base64 +import hashlib import os from contextlib import contextmanager -from datetime import timedelta +from datetime import datetime, timedelta from flask import url_for, flash, current_app, request, session, render_template from flask.ext.login import make_secure_token @@ -20,6 +21,10 @@ from .signals import user_registered, password_reset_requested +def md5(data): + return hashlib.md5(data).hexdigest() + + def generate_token(): """Generate an arbitrary URL safe token.""" return base64.urlsafe_b64encode(os.urandom(30)) @@ -101,6 +106,12 @@ def config_value(key, app=None, default=None): return get_config(app).get(key.upper(), default) +def get_max_age(key, app=None): + now = datetime.utcnow() + expires = now + get_within_delta(key + '_WITHIN', app) + return int(expires.strftime('%s')) - int(now.strftime('%s')) + + def get_within_delta(key, app=None): """Get a timedelta object from the application configuration following the internal convention of:: @@ -145,25 +156,21 @@ def send_mail(subject, recipient, template, context=None): @contextmanager -def capture_registrations(confirmation_sent_at=None): +def capture_registrations(): """Testing utility for capturing registrations. :param confirmation_sent_at: An optional datetime object to set the user's `confirmation_sent_at` to """ - users = [] - - def _on(user, app): - if confirmation_sent_at: - user.confirmation_sent_at = confirmation_sent_at - current_app.security.datastore._save_model(user) + registrations = [] - users.append(user) + def _on(data, app): + registrations.append(data) user_registered.connect(_on) try: - yield users + yield registrations finally: user_registered.disconnect(_on) @@ -175,18 +182,14 @@ def capture_reset_password_requests(reset_password_sent_at=None): :param reset_password_sent_at: An optional datetime object to set the user's `reset_password_sent_at` to """ - users = [] - - def _on(user, app): - if reset_password_sent_at: - user.reset_password_sent_at = reset_password_sent_at - current_app.security.datastore._save_model(user) + reset_requests = [] - users.append(user) + def _on(request, app): + reset_requests.append(request) password_reset_requested.connect(_on) try: - yield users + yield reset_requests finally: password_reset_requested.disconnect(_on) diff --git a/flask_security/views.py b/flask_security/views.py index 9e60b930..bbbd3383 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -17,8 +17,7 @@ from flask.ext.principal import Identity, AnonymousIdentity, identity_changed from werkzeug.local import LocalProxy -from .confirmable import confirm_by_token, \ - reset_confirmation_token, send_confirmation_instructions +from .confirmable import confirm_by_token, reset_confirmation_token from .exceptions import TokenExpiredError, UserNotFoundError, \ ConfirmationError, BadCredentialsError, ResetPasswordError from .forms import LoginForm, RegisterForm, ForgotPasswordForm, \ @@ -116,12 +115,14 @@ def register(): if form.validate_on_submit(): # Create user and send signal user = _datastore.create_user(**form.to_dict()) - - user_registered.send(user, app=app._get_current_object()) + confirm_token = None # Send confirmation instructions if necessary if _security.confirmable: - send_confirmation_instructions(user) + confirm_token = reset_confirmation_token(user) + + user_registered.send(dict(user=user, confirm_token=confirm_token), + app=app._get_current_object()) _logger.debug('User %s registered' % user) @@ -136,11 +137,10 @@ def register(): _security.register_url) -def confirm(): +def confirm(token): """View function which handles a account confirmation request.""" try: - token = request.args.get('confirmation_token', None) user = confirm_by_token(token) except ConfirmationError, e: @@ -187,21 +187,21 @@ def forgot(): forgot_password_form=form) -def reset(): +def reset(token): """View function that handles a reset password request.""" form = ResetPasswordForm(csrf_enabled=not app.testing) if form.validate_on_submit(): try: - reset_by_token(**form.to_dict()) + reset_by_token(token=token, **form.to_dict()) except ResetPasswordError, e: do_flash(str(e), 'error') except TokenExpiredError, e: do_flash('You did not reset your password within' - '%s.' % _security.reset_password_within_text) + '%s.' % _security.reset_password_within) return redirect(request.referrer or _security.reset_password_error_view) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 2817ea37..634f2ff6 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -2,6 +2,7 @@ from __future__ import with_statement +import time from datetime import datetime, timedelta from flask.ext.security.utils import capture_registrations, \ @@ -157,7 +158,8 @@ def test_register_valid_user(self): class ConfirmableTests(SecurityTest): AUTH_CONFIG = { 'SECURITY_CONFIRMABLE': True, - 'SECURITY_REGISTERABLE': True + 'SECURITY_REGISTERABLE': True, + 'SECURITY_CONFIRM_EMAIL_WITHIN': '1 seconds' } def test_register_sends_confirmation_email(self): @@ -170,44 +172,40 @@ def test_register_sends_confirmation_email(self): def test_confirm_email(self): e = 'dude@lp.com' - with capture_registrations() as users: + with capture_registrations() as registrations: self.register(e) - token = users[0].confirmation_token + token = registrations[0]['confirm_token'] - r = self.client.get('/confirm?confirmation_token=' + token, follow_redirects=True) + r = self.client.get('/confirm/' + token, follow_redirects=True) self.assertIn('Your email has been confirmed. You may now log in.', r.data) - def test_confirm_email_twice_flashes_invalid_token_msg(self): + def test_confirm_email_twice_flashes_already_confirmed_message(self): e = 'dude@lp.com' - with capture_registrations() as users: + with capture_registrations() as registrations: self.register(e) - token = users[0].confirmation_token + token = registrations[0]['confirm_token'] - url = '/confirm?confirmation_token=' + token + url = '/confirm/' + token self.client.get(url, follow_redirects=True) r = self.client.get(url, follow_redirects=True) self.assertIn('Account has already been confirmed', r.data) - def test_unprovided_token_when_confirming_email(self): - r = self.client.get('/confirm', follow_redirects=True) - self.assertIn('Confirmation token required', r.data) - def test_invalid_token_when_confirming_email(self): - r = self.client.get('/confirm?confirmation_token=invalid', follow_redirects=True) + r = self.client.get('/confirm/bogus', follow_redirects=True) self.assertIn('Invalid confirmation token', r.data) def test_expired_confirmation_token_sends_email(self): e = 'dude@lp.com' - sent_at = datetime.utcnow() - timedelta(days=15) - - with capture_registrations(confirmation_sent_at=sent_at) as users: + with capture_registrations() as registrations: self.register(e) - token = users[0].confirmation_token + token = registrations[0]['confirm_token'] + + time.sleep(3) with self.app.mail.record_messages() as outbox: - r = self.client.get('/confirm?confirmation_token=' + token, follow_redirects=True) + r = self.client.get('/confirm/' + token, follow_redirects=True) self.assertEqual(len(outbox), 1) self.assertIn(e, outbox[0].html) @@ -237,16 +235,15 @@ def test_register_valid_user_automatically_signs_in(self): class RecoverableTests(SecurityTest): AUTH_CONFIG = { - 'SECURITY_RECOVERABLE': True + 'SECURITY_RECOVERABLE': True, + 'SECURITY_RESET_PASSWORD_WITHIN': '1 seconds' } - def test_forgot_post_sends_email_and_sets_required_fields(self): - with capture_reset_password_requests() as users: + def test_forgot_post_sends_email(self): + with capture_reset_password_requests(): with self.app.mail.record_messages() as outbox: self.client.post('/forgot', data=dict(email='joe@lp.com')) self.assertEqual(len(outbox), 1) - self.assertIsNotNone(users[0].reset_password_token) - self.assertIsNotNone(users[0].reset_password_sent_at) def test_forgot_password_invalid_email(self): r = self.client.post('/forgot', @@ -255,35 +252,47 @@ def test_forgot_password_invalid_email(self): self.assertIn('The email you provided could not be found', r.data) def test_reset_password_with_valid_token(self): - u = None - with capture_reset_password_requests() as users: + with capture_reset_password_requests() as requests: r = self.client.post('/forgot', data=dict(email='joe@lp.com')) - u = users[0] + t = requests[0]['token'] - r = self.client.post('/reset', data={ - 'email': u.email, - 'token': u.reset_password_token, + r = self.client.post('/reset/' + t, data={ 'password': 'newpassword', 'password_confirm': 'newpassword' }) + r = self.authenticate('joe@lp.com', 'newpassword') self.assertIn('Hello joe@lp.com', r.data) + def test_reset_password_with_expired_token(self): + with capture_reset_password_requests() as requests: + r = self.client.post('/forgot', + data=dict(email='joe@lp.com'), + follow_redirects=True) + t = requests[0]['token'] + + time.sleep(2) + + r = self.client.post('/reset/' + t, data={ + 'password': 'newpassword', + 'password_confirm': 'newpassword' + }, follow_redirects=True) + + self.assertIn('You did not reset your password within', r.data) + def test_reset_password_twice_flashes_invalid_token_msg(self): - u = None - with capture_reset_password_requests() as users: - r = self.client.post('/forgot', data=dict(email='joe@lp.com')) - u = users[0] + with capture_reset_password_requests() as requests: + self.client.post('/forgot', data=dict(email='joe@lp.com')) + t = requests[0]['token'] data = { - 'email': u.email, - 'token': u.reset_password_token, 'password': 'newpassword', 'password_confirm': 'newpassword' } - self.client.post('/reset', data=data) - r = self.client.post('/reset', data=data, follow_redirects=True) + url = '/reset/' + t + r = self.client.post(url, data=data, follow_redirects=True) + r = self.client.post(url, data=data, follow_redirects=True) self.assertIn('Invalid reset password token', r.data) From b3de4a76d573dbf71b77c1bb9362841b84c18d9d Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 11 Jul 2012 15:14:20 -0400 Subject: [PATCH 071/234] refactor a bit --- flask_security/confirmable.py | 24 +++++++++++------------- flask_security/recoverable.py | 22 ++++++++++------------ 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index aadb5511..e86e16e3 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -96,28 +96,26 @@ def confirm_by_token(token): if md5(user.email) != data[1]: raise UserNotFoundError() + if user.confirmed_at: + raise ConfirmationError('Account has already been confirmed') + + user.confirmed_at = datetime.utcnow() + _datastore._save_model(user) + + user_confirmed.send(user, app=app._get_current_object()) + + return user + except UserNotFoundError: raise ConfirmationError('Invalid confirmation token') except SignatureExpired: sig_okay, data = serializer.loads_unsafe(token) - user = _datastore.find_user(id=data[0]) - raise TokenExpiredError(message='Confirmation token is expired', - user=user) + raise TokenExpiredError(user=_datastore.find_user(id=data[0])) except BadSignature: raise ConfirmationError('Invalid confirmation token') - if user.confirmed_at: - raise ConfirmationError('Account has already been confirmed') - - user.confirmed_at = datetime.utcnow() - _datastore._save_model(user) - - user_confirmed.send(user, app=app._get_current_object()) - - return user - def reset_confirmation_token(user): """Resets the specified user's confirmation token and sends the user diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 7fb6fbe8..00911fe6 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -86,27 +86,25 @@ def reset_by_token(token, password): if md5(user.password) != data[1]: raise UserNotFoundError() + user.password = _security.pwd_context.encrypt(password) + _datastore._save_model(user) + + send_password_reset_notice(user) + + password_reset.send(user, app=app._get_current_object()) + + return user + except UserNotFoundError: raise ResetPasswordError('Invalid reset password token') except SignatureExpired: sig_okay, data = serializer.loads_unsafe(token) - user = _datastore.find_user(id=data[0]) - raise TokenExpiredError('Reset password token is expired', user) + raise TokenExpiredError(user=_datastore.find_user(id=data[0])) except BadSignature: raise ResetPasswordError('Invalid reset password token') - user.password = _security.pwd_context.encrypt(password) - - _datastore._save_model(user) - - send_password_reset_notice(user) - - password_reset.send(user, app=app._get_current_object()) - - return user - def reset_password_reset_token(user): """Resets the specified user's reset password token and sends the user From 5e1d18c9e81e0dd8ca58d09eef4838c260c1ac51 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 11 Jul 2012 16:29:52 -0400 Subject: [PATCH 072/234] Changed token auth a bit, including the use of itsdangerous. Also added JSON authentication feature --- example/app.py | 3 +-- flask_security/confirmable.py | 10 -------- flask_security/core.py | 2 +- flask_security/decorators.py | 18 ++++++++++++-- flask_security/tokens.py | 44 +++++++---------------------------- flask_security/utils.py | 3 ++- flask_security/views.py | 43 ++++++++++++++++++++++++++++++---- tests/__init__.py | 11 +++++++++ tests/functional_tests.py | 25 +++++++++++++++++--- 9 files changed, 101 insertions(+), 58 deletions(-) diff --git a/example/app.py b/example/app.py index bbe1b4a2..ededb723 100644 --- a/example/app.py +++ b/example/app.py @@ -32,8 +32,7 @@ def create_users(): ('jill@lp.com', 'password', ['author'], True), ('tiya@lp.com', 'password', [], False)): current_app.security.datastore.create_user( - email=u[0], password=u[1], roles=u[2], active=u[3], - authentication_token='123abc') + email=u[0], password=u[1], roles=u[2], active=u[3]) def populate_data(): diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index e86e16e3..c1f981b7 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -26,16 +26,6 @@ _datastore = LocalProxy(lambda: app.security.datastore) -def find_user_by_confirmation_token(token): - """Returns a user with a matching confirmation token. - - :param token: The reset password token - """ - if not token: - raise ConfirmationError('Confirmation token required') - return _datastore.find_user(confirmation_token=token) - - def send_confirmation_instructions(user, token): """Sends the confirmation instructions email for the specified user. diff --git a/flask_security/core.py b/flask_security/core.py index 9044bc37..f5148d2a 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -19,7 +19,7 @@ from werkzeug.datastructures import ImmutableList from . import views, exceptions -from .confirmable import requires_confirmation, reset_confirmation_token +from .confirmable import requires_confirmation from .decorators import login_required from .utils import config_value as cv, get_config diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 455a3d30..b03d9f8d 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -14,10 +14,15 @@ from flask import current_app as app, Response, request, abort, redirect from flask.ext.login import login_required, login_url, current_user from flask.ext.principal import RoleNeed, Permission +from werkzeug.local import LocalProxy from . import utils +# Convenient references +_security = LocalProxy(lambda: app.security) + + _default_http_auth_msg = """

    Unauthorized

    The server could not verify that you are authorized to access the URL @@ -29,15 +34,24 @@ def _check_token(): + header_key = app.security.token_authentication_header args_key = app.security.token_authentication_key header_token = request.headers.get(header_key, None) token = request.args.get(args_key, header_token) + serializer = _security.token_auth_serializer + try: - app.security.datastore.find_user(authentication_token=token) - except: + data = serializer.loads(token) + user = app.security.datastore.find_user(id=data[0], + authentication_token=token) + + if data[1] != utils.md5(user.email): + raise Exception() + + except Exception: return False return True diff --git a/flask_security/tokens.py b/flask_security/tokens.py index 081bf2ab..f6665f0a 100644 --- a/flask_security/tokens.py +++ b/flask_security/tokens.py @@ -9,27 +9,16 @@ :license: MIT, see LICENSE for more details. """ -from datetime import datetime - from flask import current_app as app from werkzeug.local import LocalProxy -from .exceptions import BadCredentialsError, UserNotFoundError -from .utils import generate_token +from .utils import md5 # Convenient references -_datastore = LocalProxy(lambda: app.security.datastore) - +_security = LocalProxy(lambda: app.security) -def find_user_by_authentication_token(token): - """Returns a user with a matching authentication token. - - :param token: The authentication token - """ - if not token: - raise BadCredentialsError('Authentication token required') - return _datastore.find_user(authentication_token=token) +_datastore = LocalProxy(lambda: app.security.datastore) def generate_authentication_token(user): @@ -37,23 +26,8 @@ def generate_authentication_token(user): :param user: The user to work with """ - while True: - token = generate_token() - try: - find_user_by_authentication_token(token) - except UserNotFoundError: - break - - now = datetime.utcnow() - - try: - user['authentication_token'] = token - user['authentication_token_created_at'] = now - except TypeError: - user.authentication_token = token - user.authentication_token_created_at = now - - return user + data = [str(user.id), md5(user.email)] + return _security.token_auth_serializer.dumps(data) def reset_authentication_token(user): @@ -61,9 +35,10 @@ def reset_authentication_token(user): :param user: The user to work with """ - user = generate_authentication_token(user) + token = generate_authentication_token(user) + user.authentication_token = token _datastore._save_model(user) - return user.authentication_token + return token def ensure_authentication_token(user): @@ -73,5 +48,4 @@ def ensure_authentication_token(user): :param user: The user to work with """ if not user.authentication_token: - reset_authentication_token(user) - return user.authentication_token + return reset_authentication_token(user) diff --git a/flask_security/utils.py b/flask_security/utils.py index 3e43e74f..662563e0 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -15,7 +15,8 @@ from contextlib import contextmanager from datetime import datetime, timedelta -from flask import url_for, flash, current_app, request, session, render_template +from flask import url_for, flash, current_app, request, session, \ + render_template from flask.ext.login import make_secure_token from .signals import user_registered, password_reset_requested diff --git a/flask_security/views.py b/flask_security/views.py index bbbd3383..2e352367 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -12,9 +12,10 @@ from datetime import datetime from flask import current_app as app, redirect, request, session, \ - render_template + render_template, jsonify from flask.ext.login import login_user, logout_user from flask.ext.principal import Identity, AnonymousIdentity, identity_changed +from werkzeug.datastructures import MultiDict from werkzeug.local import LocalProxy from .confirmable import confirm_by_token, reset_confirmation_token @@ -25,6 +26,7 @@ from .recoverable import reset_by_token, \ reset_password_reset_token from .signals import user_registered +from .tokens import generate_authentication_token from .utils import get_post_login_redirect, do_flash, get_remember_token @@ -42,6 +44,9 @@ def _do_login(user, remember=True): if not login_user(user, remember): return False + if user.authentication_token is None: + user.authentication_token = generate_authentication_token(user) + if remember: user.remember_token = get_remember_token(user.email, user.password) @@ -65,15 +70,45 @@ def _do_login(user, remember=True): return True +def _json_auth_ok(user): + return jsonify({ + "meta": { + "code": 200 + }, + "response": { + "user": { + "id": str(user.id), + "authentication_token": user.authentication_token + } + } + }) + + +def _json_auth_error(msg): + resp = jsonify({ + "meta": { + "code": 400 + }, + "response": { + "error": msg + } + }) + resp.status_code = 400 + return resp + + def authenticate(): """View function which handles an authentication request.""" - form = LoginForm() + form = LoginForm(MultiDict(request.json) if request.json else request.form) try: user = _security.auth_provider.authenticate(form) if _do_login(user, remember=form.remember.data): + if request.json: + return _json_auth_ok(user) + return redirect(get_post_login_redirect()) raise BadCredentialsError('Inactive user') @@ -81,8 +116,8 @@ def authenticate(): except BadCredentialsError, e: msg = str(e) - except Exception, e: - msg = 'Unknown authentication error' + if request.json: + return _json_auth_error(msg) do_flash(msg, 'error') _logger.debug('Unsuccessful authentication attempt: %s' % msg) diff --git a/tests/__init__.py b/tests/__init__.py index 7af88b90..09bbf932 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -36,6 +36,17 @@ def authenticate(self, email="matt@lp.com", password="password", endpoint=None): data = dict(email=email, password=password) return self._post(endpoint or '/auth', data=data) + def json_authenticate(self, email="matt@lp.com", password="password", endpoint=None): + data = """ +{ + "email": "%s", + "password": "%s" +} +""" + return self._post(endpoint or '/auth', + content_type="application/json", + data=data % (email, password)) + def logout(self, endpoint=None): return self._get(endpoint or '/logout', follow_redirects=True) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 634f2ff6..5f6b6be8 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -3,7 +3,11 @@ from __future__ import with_statement import time -from datetime import datetime, timedelta + +try: + import simplejson as json +except ImportError: + import json from flask.ext.security.utils import capture_registrations, \ capture_reset_password_requests @@ -95,12 +99,27 @@ def test_multiple_role_required(self): r = self._get("/admin_and_editor") self.assertIn('Admin and Editor Page', r.data) + def test_ok_json_auth(self): + r = self.json_authenticate() + self.assertIn('"code": 200', r.data) + + def test_invalid_json_auth(self): + r = self.json_authenticate(password='junk') + self.assertIn('"code": 400', r.data) + def test_token_auth_via_querystring_valid_token(self): - r = self._get('/token?auth_token=123abc') + r = self.json_authenticate() + data = json.loads(r.data) + token = data['response']['user']['authentication_token'] + r = self._get('/token?auth_token=' + token) self.assertIn('Token Authentication', r.data) def test_token_auth_via_header_valid_token(self): - r = self._get('/token', headers={"X-Auth-Token": '123abc'}) + r = self.json_authenticate() + data = json.loads(r.data) + token = data['response']['user']['authentication_token'] + headers = {"X-Auth-Token": token} + r = self._get('/token', headers=headers) self.assertIn('Token Authentication', r.data) def test_token_auth_via_querystring_invalid_token(self): From f35fa7f4d0b146410b57403c3f793b8f84c9dfb2 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 11 Jul 2012 16:34:00 -0400 Subject: [PATCH 073/234] Update dependency --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e4d9ad67..3e9fe732 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,8 @@ 'Flask-Principal>=0.3', 'Flask-WTF>=0.5.4', 'Flask-Mail>=0.6.1', - 'passlib>=1.5.3' + 'itsdangerous>=0.15' + 'passlib>=1.5.3', ], test_suite='nose.collector', tests_require=[ From e7bd1533c4f74d35cf08b5a1937db39b2bf9faf7 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 11 Jul 2012 16:38:26 -0400 Subject: [PATCH 074/234] Move it --- flask_security/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flask_security/views.py b/flask_security/views.py index 2e352367..e6852d09 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -116,11 +116,12 @@ def authenticate(): except BadCredentialsError, e: msg = str(e) + _logger.debug('Unsuccessful authentication attempt: %s' % msg) + if request.json: return _json_auth_error(msg) do_flash(msg, 'error') - _logger.debug('Unsuccessful authentication attempt: %s' % msg) return redirect(request.referrer or _security.login_manager.login_view) From ddc503d296afb9a6e760926c3c2e07016cafe77b Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 11 Jul 2012 17:02:44 -0400 Subject: [PATCH 075/234] Mising comma --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3e9fe732..3f9f2f04 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,7 @@ 'Flask-Principal>=0.3', 'Flask-WTF>=0.5.4', 'Flask-Mail>=0.6.1', - 'itsdangerous>=0.15' + 'itsdangerous>=0.15', 'passlib>=1.5.3', ], test_suite='nose.collector', From 0befa34dc8e849f29e9f4cddf7ed3a487557551a Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 11 Jul 2012 18:26:10 -0400 Subject: [PATCH 076/234] Trying to fix build, I don't think Travis likes the quickness of the token expiration tests --- flask_security/views.py | 22 +++++++++++++--- tests/functional_tests.py | 54 +++++++++++++++++++++++++-------------- 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/flask_security/views.py b/flask_security/views.py index e6852d09..2ef37c79 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -169,8 +169,7 @@ def register(): return redirect(_security.post_register_view or _security.post_login_view) - return redirect(request.referrer or - _security.register_url) + return redirect(request.referrer or _security.register_url) def confirm(token): @@ -180,20 +179,28 @@ def confirm(token): user = confirm_by_token(token) except ConfirmationError, e: + _logger.debug('Confirmation error: ' + str(e)) + do_flash(str(e), 'error') + return redirect('/') # TODO: Don't just redirect to root except TokenExpiredError, e: + reset_confirmation_token(e.user) msg = 'You did not confirm your email within %s. ' \ 'A new confirmation code has been sent to %s' % ( _security.confirm_email_within, e.user.email) + _logger.debug('Attempted account confirmation but token was expired') + do_flash(msg, 'error') + return redirect('/') # TODO: Don't redirect to root _logger.debug('User %s confirmed' % user) + do_flash('Your email has been confirmed. You may now log in.', 'success') return redirect(_security.post_confirm_view or @@ -211,10 +218,15 @@ def forgot(): reset_password_reset_token(user) + _logger.debug('%s requested to reset their password' % user) + do_flash('Instructions to reset your password have been ' 'sent to %s' % user.email, 'success') except UserNotFoundError: + _logger.debug('A reset password request was made for %s but ' + 'that email does not exist.' % form.email.data) + do_flash('The email you provided could not be found', 'error') return redirect(_security.post_forgot_view) @@ -233,10 +245,14 @@ def reset(token): reset_by_token(token=token, **form.to_dict()) except ResetPasswordError, e: + _logger.debug('Password reset error: ' + str(e)) + do_flash(str(e), 'error') except TokenExpiredError, e: - do_flash('You did not reset your password within' + _logger.debug('Attempted password reset but token was expired') + + do_flash('You did not reset your password within ' '%s.' % _security.reset_password_within) return redirect(request.referrer or diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 5f6b6be8..230e5044 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -177,8 +177,7 @@ def test_register_valid_user(self): class ConfirmableTests(SecurityTest): AUTH_CONFIG = { 'SECURITY_CONFIRMABLE': True, - 'SECURITY_REGISTERABLE': True, - 'SECURITY_CONFIRM_EMAIL_WITHIN': '1 seconds' + 'SECURITY_REGISTERABLE': True } def test_register_sends_confirmation_email(self): @@ -214,6 +213,14 @@ def test_invalid_token_when_confirming_email(self): r = self.client.get('/confirm/bogus', follow_redirects=True) self.assertIn('Invalid confirmation token', r.data) + +class ExpiredConfirmationTest(SecurityTest): + AUTH_CONFIG = { + 'SECURITY_CONFIRMABLE': True, + 'SECURITY_REGISTERABLE': True, + 'SECURITY_CONFIRM_EMAIL_WITHIN': '1 seconds' + } + def test_expired_confirmation_token_sends_email(self): e = 'dude@lp.com' @@ -255,7 +262,6 @@ class RecoverableTests(SecurityTest): AUTH_CONFIG = { 'SECURITY_RECOVERABLE': True, - 'SECURITY_RESET_PASSWORD_WITHIN': '1 seconds' } def test_forgot_post_sends_email(self): @@ -271,33 +277,19 @@ def test_forgot_password_invalid_email(self): self.assertIn('The email you provided could not be found', r.data) def test_reset_password_with_valid_token(self): - with capture_reset_password_requests() as requests: - r = self.client.post('/forgot', data=dict(email='joe@lp.com')) - t = requests[0]['token'] - - r = self.client.post('/reset/' + t, data={ - 'password': 'newpassword', - 'password_confirm': 'newpassword' - }) - - r = self.authenticate('joe@lp.com', 'newpassword') - self.assertIn('Hello joe@lp.com', r.data) - - def test_reset_password_with_expired_token(self): with capture_reset_password_requests() as requests: r = self.client.post('/forgot', data=dict(email='joe@lp.com'), follow_redirects=True) t = requests[0]['token'] - time.sleep(2) - r = self.client.post('/reset/' + t, data={ 'password': 'newpassword', 'password_confirm': 'newpassword' }, follow_redirects=True) - self.assertIn('You did not reset your password within', r.data) + r = self.authenticate('joe@lp.com', 'newpassword') + self.assertIn('Hello joe@lp.com', r.data) def test_reset_password_twice_flashes_invalid_token_msg(self): with capture_reset_password_requests() as requests: @@ -315,6 +307,30 @@ def test_reset_password_twice_flashes_invalid_token_msg(self): self.assertIn('Invalid reset password token', r.data) +class ExpiredResetPasswordTest(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_RECOVERABLE': True, + 'SECURITY_RESET_PASSWORD_WITHIN': '1 seconds' + } + + def test_reset_password_with_expired_token(self): + with capture_reset_password_requests() as requests: + r = self.client.post('/forgot', + data=dict(email='joe@lp.com'), + follow_redirects=True) + t = requests[0]['token'] + + time.sleep(2) + + r = self.client.post('/reset/' + t, data={ + 'password': 'newpassword', + 'password_confirm': 'newpassword' + }, follow_redirects=True) + + self.assertIn('You did not reset your password within', r.data) + + class MongoEngineSecurityTests(DefaultSecurityTests): def _create_app(self, auth_config): From da031b8d15c92c3464aa94880e274468464da209 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 12 Jul 2012 11:29:42 -0400 Subject: [PATCH 077/234] Simplify routing a tiny bit --- flask_security/confirmable.py | 8 +- flask_security/core.py | 26 +++--- flask_security/forms.py | 36 +++++++- flask_security/recoverable.py | 2 +- .../templates/security/confirmations/new.html | 4 +- .../templates/security/passwords/edit.html | 2 +- .../templates/security/passwords/new.html | 4 +- flask_security/views.py | 89 ++++++++++++------- tests/functional_tests.py | 12 +-- 9 files changed, 120 insertions(+), 63 deletions(-) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index c1f981b7..05d0d3a1 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -31,14 +31,14 @@ def send_confirmation_instructions(user, token): :param user: The user to send the instructions to """ - url = url_for('flask_security.confirm', - token=token) + url = url_for('flask_security.confirm_account', token=token) confirmation_link = request.url_root[:-1] + url + ctx = dict(user=user, confirmation_link=confirmation_link) + send_mail('Please confirm your email', user.email, - 'confirmation_instructions', - dict(user=user, confirmation_link=confirmation_link)) + 'confirmation_instructions', ctx) confirm_instructions_sent.send(user, app=app._get_current_object()) diff --git a/flask_security/core.py b/flask_security/core.py index f5148d2a..228aece3 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -32,10 +32,10 @@ 'AUTH_URL': '/auth', 'LOGOUT_URL': '/logout', 'REGISTER_URL': '/register', - 'FORGOT_URL': '/forgot', - 'RESET_URL': '/reset/', - 'CONFIRM_URL': '/confirm/', + 'RESET_URL': '/reset', + 'CONFIRM_URL': '/confirm', 'LOGIN_VIEW': '/login', + 'CONFIRM_ERROR_VIEW': '/confirm', 'POST_LOGIN_VIEW': '/', 'POST_LOGOUT_VIEW': '/', 'POST_FORGOT_VIEW': '/', @@ -141,20 +141,24 @@ def _create_blueprint(app): if cv('REGISTERABLE', app=app): bp.route(cv('REGISTER_URL', app=app), - methods=['POST'], - endpoint='register')(views.register) + methods=['GET', 'POST'], + endpoint='register')(views.register_user) if cv('RECOVERABLE', app=app): - bp.route(cv('FORGOT_URL', app=app), - methods=['POST'], - endpoint='forgot')(views.forgot) bp.route(cv('RESET_URL', app=app), - methods=['POST'], - endpoint='reset')(views.reset) + methods=['GET', 'POST'], + endpoint='forgot_password')(views.forgot_password) + bp.route(cv('RESET_URL', app=app) + '/', + methods=['GET', 'POST'], + endpoint='reset_password')(views.reset_password) if cv('CONFIRMABLE', app=app): bp.route(cv('CONFIRM_URL', app=app), - endpoint='confirm')(views.confirm) + methods=['GET', 'POST'], + endpoint='send_confirmation')(views.send_confirmation) + bp.route(cv('CONFIRM_URL', app=app) + '/', + methods=['GET', 'POST'], + endpoint='confirm_account')(views.confirm_account) return bp diff --git a/flask_security/forms.py b/flask_security/forms.py index b8cd0944..4be9bbe4 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -9,9 +9,23 @@ :license: MIT, see LICENSE for more details. """ -from flask import request +from flask import request, current_app as app from flask.ext.wtf import Form, TextField, PasswordField, SubmitField, \ - HiddenField, Required, BooleanField, EqualTo, Email + HiddenField, Required, BooleanField, EqualTo, Email, ValidationError +from werkzeug.local import LocalProxy + +from .exceptions import UserNotFoundError + + +# Convenient reference +_datastore = LocalProxy(lambda: app.security.datastore) + + +def valid_user_email(form, field): + try: + _datastore.find_user(email=field.data) + except UserNotFoundError: + raise ValidationError('Invalid email address') class EmailFormMixin(): @@ -20,6 +34,13 @@ class EmailFormMixin(): Email(message="Invalid email address")]) +class UserEmailFormMixin(): + email = TextField("Email Address", + validators=[Required(message="Email not provided"), + Email(message="Invalid email address"), + valid_user_email]) + + class PasswordFormMixin(): password = PasswordField("Password", validators=[Required(message="Password not provided")]) @@ -30,7 +51,16 @@ class PasswordConfirmFormMixin(): validators=[EqualTo('password', message="Passwords do not match")]) -class ForgotPasswordForm(Form, EmailFormMixin): +class ResendConfirmationForm(Form, UserEmailFormMixin): + """The default forgot password form""" + + submit = SubmitField("Resend Confirmation Instructions") + + def to_dict(self): + return dict(email=self.email.data) + + +class ForgotPasswordForm(Form, UserEmailFormMixin): """The default forgot password form""" submit = SubmitField("Recover Password") diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 00911fe6..ee2144d1 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -31,7 +31,7 @@ def send_reset_password_instructions(user, reset_token): :param user: The user to send the instructions to """ - url = url_for('flask_security.reset', + url = url_for('flask_security.reset_password', token=reset_token) reset_link = request.url_root[:-1] + url diff --git a/flask_security/templates/security/confirmations/new.html b/flask_security/templates/security/confirmations/new.html index 03e1e128..7bece483 100644 --- a/flask_security/templates/security/confirmations/new.html +++ b/flask_security/templates/security/confirmations/new.html @@ -1,6 +1,6 @@ -{% include "../messages.html" %} +{% include "security/messages.html" %}

    Resend confirmation instructions

    - + {{ reset_confirmation_form.hidden_tag() }} {{ reset_confirmation_form.email.label }} {{ reset_confirmation_form.email }} {{ reset_confirmation_form.submit }} diff --git a/flask_security/templates/security/passwords/edit.html b/flask_security/templates/security/passwords/edit.html index a0a3ec93..b17de1a7 100644 --- a/flask_security/templates/security/passwords/edit.html +++ b/flask_security/templates/security/passwords/edit.html @@ -1,6 +1,6 @@ {% include "../messages.html" %}

    Change password

    - + {{ reset_password_form.hidden_tag() }} {{ reset_password_form.password.label }} {{ reset_password_form.password }}
    {{ reset_password_form.password_confirm.label }} {{ reset_password_form.password_confirm }}
    diff --git a/flask_security/templates/security/passwords/new.html b/flask_security/templates/security/passwords/new.html index af174f32..c5b2a934 100644 --- a/flask_security/templates/security/passwords/new.html +++ b/flask_security/templates/security/passwords/new.html @@ -1,6 +1,6 @@ -{% include "../messages.html" %} +{% include "security/messages.html" %}

    Send reset password instructions

    - + {{ forgot_password_form.hidden_tag() }} {{ forgot_password_form.email.label }} {{ forgot_password_form.email }} {{ forgot_password_form.submit }} diff --git a/flask_security/views.py b/flask_security/views.py index 2ef37c79..9b1ace87 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -19,15 +19,16 @@ from werkzeug.local import LocalProxy from .confirmable import confirm_by_token, reset_confirmation_token -from .exceptions import TokenExpiredError, UserNotFoundError, \ - ConfirmationError, BadCredentialsError, ResetPasswordError +from .exceptions import TokenExpiredError, ConfirmationError, \ + BadCredentialsError, ResetPasswordError from .forms import LoginForm, RegisterForm, ForgotPasswordForm, \ - ResetPasswordForm + ResetPasswordForm, ResendConfirmationForm from .recoverable import reset_by_token, \ reset_password_reset_token from .signals import user_registered from .tokens import generate_authentication_token -from .utils import get_post_login_redirect, do_flash, get_remember_token +from .utils import get_url, get_post_login_redirect, do_flash, \ + get_remember_token # Convenient references @@ -142,50 +143,71 @@ def logout(): _security.post_logout_view) -def register(): +def register_user(): """View function which handles a registration request.""" form = RegisterForm(csrf_enabled=not app.testing) - # Exit early if the form doesn't validate if form.validate_on_submit(): - # Create user and send signal - user = _datastore.create_user(**form.to_dict()) - confirm_token = None + # Create user + u = _datastore.create_user(**form.to_dict()) # Send confirmation instructions if necessary - if _security.confirmable: - confirm_token = reset_confirmation_token(user) + t = reset_confirmation_token(u) if _security.confirmable else None - user_registered.send(dict(user=user, confirm_token=confirm_token), - app=app._get_current_object()) + data = dict(user=u, confirm_token=t) + user_registered.send(data, app=app._get_current_object()) - _logger.debug('User %s registered' % user) + _logger.debug('User %s registered' % u) # Login the user if allowed if not _security.confirmable or _security.login_without_confirmation: - _do_login(user) + _do_login(u) return redirect(_security.post_register_view or _security.post_login_view) - return redirect(request.referrer or _security.register_url) + return render_template('security/registrations/new.html', + register_user_form=form) -def confirm(token): +def send_confirmation(): + form = ResendConfirmationForm() + + if form.validate_on_submit(): + user = _datastore.find_user(email=form.email.data) + + reset_confirmation_token(user) + + _logger.debug('%s request confirmation instructions' % user) + + msg = 'A new confirmation code has been sent to ' + user.email + do_flash(msg, 'info') + + else: + for key, value in form.errors.items(): + do_flash(value[0], 'error') + + return render_template('security/confirmations/new.html', + reset_confirmation_form=form) + + +def confirm_account(token): """View function which handles a account confirmation request.""" + error = False try: user = confirm_by_token(token) except ConfirmationError, e: + error = True + _logger.debug('Confirmation error: ' + str(e)) do_flash(str(e), 'error') - return redirect('/') # TODO: Don't just redirect to root - except TokenExpiredError, e: + error = True reset_confirmation_token(e.user) @@ -197,7 +219,8 @@ def confirm(token): do_flash(msg, 'error') - return redirect('/') # TODO: Don't redirect to root + if error: + return redirect(get_url(_security.confirm_error_view)) _logger.debug('User %s confirmed' % user) @@ -207,35 +230,35 @@ def confirm(token): _security.post_login_view) -def forgot(): +def forgot_password(): """View function that handles a forgotten password request.""" form = ForgotPasswordForm(csrf_enabled=not app.testing) if form.validate_on_submit(): - try: - user = _datastore.find_user(**form.to_dict()) + user = _datastore.find_user(**form.to_dict()) - reset_password_reset_token(user) + reset_password_reset_token(user) - _logger.debug('%s requested to reset their password' % user) + _logger.debug('%s requested to reset their password' % user) - do_flash('Instructions to reset your password have been ' - 'sent to %s' % user.email, 'success') + do_flash('Instructions to reset your password have been ' + 'sent to %s' % user.email, 'success') - except UserNotFoundError: - _logger.debug('A reset password request was made for %s but ' - 'that email does not exist.' % form.email.data) + return redirect(_security.post_forgot_view) - do_flash('The email you provided could not be found', 'error') + else: + _logger.debug('A reset password request was made for %s but ' + 'that email does not exist.' % form.email.data) - return redirect(_security.post_forgot_view) + for key, value in form.errors.items(): + do_flash(value[0], 'error') return render_template('security/passwords/new.html', forgot_password_form=form) -def reset(token): +def reset_password(token): """View function that handles a reset password request.""" form = ResetPasswordForm(csrf_enabled=not app.testing) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 230e5044..f55505f5 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -267,18 +267,18 @@ class RecoverableTests(SecurityTest): def test_forgot_post_sends_email(self): with capture_reset_password_requests(): with self.app.mail.record_messages() as outbox: - self.client.post('/forgot', data=dict(email='joe@lp.com')) + self.client.post('/reset', data=dict(email='joe@lp.com')) self.assertEqual(len(outbox), 1) def test_forgot_password_invalid_email(self): - r = self.client.post('/forgot', + r = self.client.post('/reset', data=dict(email='larry@lp.com'), follow_redirects=True) - self.assertIn('The email you provided could not be found', r.data) + self.assertIn('Invalid email address', r.data) def test_reset_password_with_valid_token(self): with capture_reset_password_requests() as requests: - r = self.client.post('/forgot', + r = self.client.post('/reset', data=dict(email='joe@lp.com'), follow_redirects=True) t = requests[0]['token'] @@ -293,7 +293,7 @@ def test_reset_password_with_valid_token(self): def test_reset_password_twice_flashes_invalid_token_msg(self): with capture_reset_password_requests() as requests: - self.client.post('/forgot', data=dict(email='joe@lp.com')) + self.client.post('/reset', data=dict(email='joe@lp.com')) t = requests[0]['token'] data = { @@ -316,7 +316,7 @@ class ExpiredResetPasswordTest(SecurityTest): def test_reset_password_with_expired_token(self): with capture_reset_password_requests() as requests: - r = self.client.post('/forgot', + r = self.client.post('/reset', data=dict(email='joe@lp.com'), follow_redirects=True) t = requests[0]['token'] From 8d1bdca1deb44737e559c65b771d25912d2e9e4e Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 12 Jul 2012 12:18:08 -0400 Subject: [PATCH 078/234] Change password reset view to use packaged template --- flask_security/core.py | 5 +++-- flask_security/templates/security/passwords/edit.html | 4 ++-- flask_security/views.py | 5 +++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 228aece3..f254e0f8 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -245,8 +245,9 @@ def init_app(self, app, datastore): identity_loaded.connect_via(app)(_on_identity_loaded) - app.register_blueprint(_create_blueprint(app), - url_prefix=cv('URL_PREFIX', app=app)) + bp = _create_blueprint(app) + pre = cv('URL_PREFIX', app=app) + app.register_blueprint(bp, url_prefix=pre) app.security = self diff --git a/flask_security/templates/security/passwords/edit.html b/flask_security/templates/security/passwords/edit.html index b17de1a7..723773ea 100644 --- a/flask_security/templates/security/passwords/edit.html +++ b/flask_security/templates/security/passwords/edit.html @@ -1,6 +1,6 @@ -{% include "../messages.html" %} +{% include "security/messages.html" %}

    Change password

    - + {{ reset_password_form.hidden_tag() }} {{ reset_password_form.password.label }} {{ reset_password_form.password }}
    {{ reset_password_form.password_confirm.label }} {{ reset_password_form.password_confirm }}
    diff --git a/flask_security/views.py b/flask_security/views.py index 9b1ace87..a4567277 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -278,5 +278,6 @@ def reset_password(token): do_flash('You did not reset your password within ' '%s.' % _security.reset_password_within) - return redirect(request.referrer or - _security.reset_password_error_view) + return render_template('security/passwords/edit.html', + reset_password_form=form, + password_reset_token=token) From dfcb3cdcc6b55eac506df174ad5b22329433202a Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 12 Jul 2012 12:47:21 -0400 Subject: [PATCH 079/234] Add customizable unauthorized URL. Fixes #23 --- example/app.py | 4 +++ example/templates/unauthorized.html | 3 ++ flask_security/core.py | 1 + flask_security/decorators.py | 36 +++++++++++++------ .../templates/security/passwords/edit.html | 2 +- tests/functional_tests.py | 14 ++++++-- 6 files changed, 47 insertions(+), 13 deletions(-) create mode 100644 example/templates/unauthorized.html diff --git a/example/app.py b/example/app.py index ededb723..11f74a84 100644 --- a/example/app.py +++ b/example/app.py @@ -106,6 +106,10 @@ def admin_and_editor(): def admin_or_editor(): return render_template('index.html', content='Admin or Editor Page') + @app.route('/unauthorized') + def unauthorized(): + return render_template('unauthorized.html') + return app diff --git a/example/templates/unauthorized.html b/example/templates/unauthorized.html new file mode 100644 index 00000000..10e9c2b7 --- /dev/null +++ b/example/templates/unauthorized.html @@ -0,0 +1,3 @@ +{% include "_messages.html" %} +{% include "_nav.html" %} +

    You are not allowed to access the requested resouce

    diff --git a/flask_security/core.py b/flask_security/core.py index f254e0f8..012145b6 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -42,6 +42,7 @@ 'RESET_PASSWORD_ERROR_VIEW': '/', 'POST_REGISTER_VIEW': None, 'POST_CONFIRM_VIEW': None, + 'UNAUTHORIZED_VIEW': None, 'DEFAULT_ROLES': [], 'CONFIRMABLE': False, 'REGISTERABLE': False, diff --git a/flask_security/decorators.py b/flask_security/decorators.py index b03d9f8d..e4848e9c 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -11,7 +11,7 @@ from functools import wraps -from flask import current_app as app, Response, request, abort, redirect +from flask import current_app as app, Response, request, redirect from flask.ext.login import login_required, login_url, current_user from flask.ext.principal import RoleNeed, Permission from werkzeug.local import LocalProxy @@ -23,18 +23,26 @@ _security = LocalProxy(lambda: app.security) -_default_http_auth_msg = """ +_default_unauthorized_txt = """

    Unauthorized

    The server could not verify that you are authorized to access the URL requested. You either supplied the wrong credentials (e.g. a bad password), or your browser doesn't understand how to supply the credentials required.

    -

    In case you are allowed to request the document, please check your - user-id and password and try again.

    """ -def _check_token(): +def _get_unauthorized_response(text=None, headers=None): + text = text or _default_unauthorized_txt + headers = headers or {} + return Response(_default_unauthorized_txt, 401, headers) + + +def _get_unauthorized_view(): + cv = utils.get_url(utils.config_value('UNAUTHORIZED_VIEW')) + return (cv or request.referrer or '/') + +def _check_token(): header_key = app.security.token_authentication_header args_key = app.security.token_authentication_key @@ -77,7 +85,7 @@ def decorated(*args, **kwargs): if _check_http_auth(): return fn(*args, **kwargs) - return Response(_default_http_auth_msg, 401, headers) + return _get_unauthorized_response(headers=headers) return decorated @@ -89,7 +97,7 @@ def decorated(*args, **kwargs): if _check_token(): return fn(*args, **kwargs) - abort(401) + return _get_unauthorized_response() return decorated @@ -111,6 +119,7 @@ def dashboard(): perms = [Permission(RoleNeed(role)) for role in roles] def wrapper(fn): + @wraps(fn) def decorated_view(*args, **kwargs): if not current_user.is_authenticated(): @@ -120,10 +129,14 @@ def decorated_view(*args, **kwargs): for perm in perms: if not perm.can(): app.logger.debug('Identity does not provide the ' - 'roles: %s' % [r for r in roles]) - return redirect(request.referrer or '/') + 'roles: %s' % [r for r in roles]) + + return redirect(_get_unauthorized_view()) + return fn(*args, **kwargs) + return decorated_view + return wrapper @@ -144,6 +157,7 @@ def create_post(): perm = Permission(*[RoleNeed(role) for role in roles]) def wrapper(fn): + @wraps(fn) def decorated_view(*args, **kwargs): if not current_user.is_authenticated(): @@ -162,6 +176,8 @@ def decorated_view(*args, **kwargs): utils.do_flash('You do not have permission to ' 'view this resource', 'error') - return redirect(request.referrer or '/') + return redirect(_get_unauthorized_view()) + return decorated_view + return wrapper diff --git a/flask_security/templates/security/passwords/edit.html b/flask_security/templates/security/passwords/edit.html index 723773ea..e806d0d2 100644 --- a/flask_security/templates/security/passwords/edit.html +++ b/flask_security/templates/security/passwords/edit.html @@ -1,6 +1,6 @@ {% include "security/messages.html" %}

    Change password

    - + {{ reset_password_form.hidden_tag() }} {{ reset_password_form.password.label }} {{ reset_password_form.password }}
    {{ reset_password_form.password_confirm.label }} {{ reset_password_form.password_confirm }}
    diff --git a/tests/functional_tests.py b/tests/functional_tests.py index f55505f5..925b5ba9 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -140,7 +140,8 @@ class ConfiguredURLTests(SecurityTest): 'SECURITY_LOGIN_VIEW': '/custom_login', 'SECURITY_POST_LOGIN_VIEW': '/post_login', 'SECURITY_POST_LOGOUT_VIEW': '/post_logout', - 'SECURITY_POST_REGISTER_VIEW': '/post_register' + 'SECURITY_POST_REGISTER_VIEW': '/post_register', + 'SECURITY_UNAUTHORIZED_VIEW': '/unauthorized' } def test_login_view(self): @@ -157,10 +158,19 @@ def test_logout(self): self.assertIn('Post Logout', r.data) def test_register(self): - data = dict(email='dude@lp.com', password='password', password_confirm='password') + data = dict(email='dude@lp.com', + password='password', + password_confirm='password') + r = self.client.post('/register', data=data, follow_redirects=True) self.assertIn('Post Register', r.data) + def test_unauthorized(self): + self.authenticate("joe@lp.com", endpoint="/custom_auth") + r = self._get("/admin", follow_redirects=True) + msg = 'You are not allowed to access the requested resouce' + self.assertIn(msg, r.data) + class RegisterableTests(SecurityTest): AUTH_CONFIG = { From 2e9c62b4f835b988d3a6615b631d0032c231bcd4 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 12 Jul 2012 13:15:58 -0400 Subject: [PATCH 080/234] Refactor decorators a bit --- flask_security/decorators.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/flask_security/decorators.py b/flask_security/decorators.py index e4848e9c..6e887f78 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -39,7 +39,8 @@ def _get_unauthorized_response(text=None, headers=None): def _get_unauthorized_view(): cv = utils.get_url(utils.config_value('UNAUTHORIZED_VIEW')) - return (cv or request.referrer or '/') + utils.do_flash('You do not have permission to view this resource', 'error') + return redirect(cv or request.referrer or '/') def _check_token(): @@ -116,22 +117,19 @@ def dashboard(): :param args: The required roles. """ - perms = [Permission(RoleNeed(role)) for role in roles] - def wrapper(fn): @wraps(fn) + @login_required def decorated_view(*args, **kwargs): - if not current_user.is_authenticated(): - login_view = app.security.login_manager.login_view - return redirect(login_url(login_view, request.url)) + perms = [Permission(RoleNeed(role)) for role in roles] for perm in perms: if not perm.can(): app.logger.debug('Identity does not provide the ' 'roles: %s' % [r for r in roles]) - return redirect(_get_unauthorized_view()) + return _get_unauthorized_view() return fn(*args, **kwargs) @@ -154,15 +152,12 @@ def create_post(): :param args: The possible roles. """ - perm = Permission(*[RoleNeed(role) for role in roles]) - def wrapper(fn): @wraps(fn) + @login_required def decorated_view(*args, **kwargs): - if not current_user.is_authenticated(): - login_view = app.security.login_manager.login_view - return redirect(login_url(login_view, request.url)) + perm = Permission(*[RoleNeed(role) for role in roles]) if perm.can(): return fn(*args, **kwargs) @@ -173,10 +168,7 @@ def decorated_view(*args, **kwargs): app.logger.debug('Current user does not provide a ' 'required role. Accepted: %s Provided: %s' % (r1, r2)) - utils.do_flash('You do not have permission to ' - 'view this resource', 'error') - - return redirect(_get_unauthorized_view()) + return _get_unauthorized_view() return decorated_view From a2d31d1d8d0102f3144346b1516c02770ce6aa2a Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 12 Jul 2012 13:24:59 -0400 Subject: [PATCH 081/234] Add configurable default http auth header --- flask_security/core.py | 3 ++- flask_security/decorators.py | 24 +++++++++++++++--------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 012145b6..dd347d08 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -56,7 +56,8 @@ 'TOKEN_AUTHENTICATION_HEADER': 'X-Auth-Token', 'CONFIRM_SALT': 'confirm-salt', 'RESET_SALT': 'reset-salt', - 'AUTH_SALT': 'auth-salt' + 'AUTH_SALT': 'auth-salt', + 'DEFAULT_HTTP_AUTH_HEADER': 'Basic realm="Login Required"' } diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 6e887f78..e909b49c 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -17,6 +17,7 @@ from werkzeug.local import LocalProxy from . import utils +from .exceptions import UserNotFoundError # Convenient references @@ -71,24 +72,29 @@ def _check_http_auth(): try: user = app.security.datastore.find_user(email=auth.username) - except: + except UserNotFoundError: return False return app.security.pwd_context.verify(auth.password, user.password) -def http_auth_required(fn): +def http_auth_required(auth_header=None): """Decorator that protects endpoints using Basic HTTP authentication.""" - headers = {'WWW-Authenticate': 'Basic realm="Login Required"'} + def wrapper(fn): - @wraps(fn) - def decorated(*args, **kwargs): - if _check_http_auth(): - return fn(*args, **kwargs) + @wraps(fn) + def decorated(*args, **kwargs): + if _check_http_auth(): + return fn(*args, **kwargs) - return _get_unauthorized_response(headers=headers) + header = auth_header or _security.default_http_auth_header + headers = {'WWW-Authenticate': header} - return decorated + return _get_unauthorized_response(headers=headers) + + return decorated + + return wrapper def auth_token_required(fn): From dcdfb4d3e7c6f30c7680ae9512e37f9163d5458f Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 12 Jul 2012 14:16:54 -0400 Subject: [PATCH 082/234] Add configurable http auth realm and optional realm specification in http_auth_required decorator --- example/app.py | 5 +++++ flask_security/core.py | 2 +- flask_security/decorators.py | 19 +++++++++++-------- tests/functional_tests.py | 36 ++++++++++++++++++++++++++++++++++-- 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/example/app.py b/example/app.py index 11f74a84..a649e3b2 100644 --- a/example/app.py +++ b/example/app.py @@ -78,6 +78,11 @@ def post_login(): def http(): return render_template('index.html', content='HTTP Authentication') + @app.route('/http_custom_realm') + @http_auth_required('My Realm') + def http_custom_realm(): + return render_template('index.html', content='HTTP Authentication') + @app.route('/token') @auth_token_required def token(): diff --git a/flask_security/core.py b/flask_security/core.py index dd347d08..b12fb1d4 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -57,7 +57,7 @@ 'CONFIRM_SALT': 'confirm-salt', 'RESET_SALT': 'reset-salt', 'AUTH_SALT': 'auth-salt', - 'DEFAULT_HTTP_AUTH_HEADER': 'Basic realm="Login Required"' + 'DEFAULT_HTTP_AUTH_REALM': 'Login Required' } diff --git a/flask_security/decorators.py b/flask_security/decorators.py index e909b49c..c5a100f0 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -78,23 +78,26 @@ def _check_http_auth(): return app.security.pwd_context.verify(auth.password, user.password) -def http_auth_required(auth_header=None): +def http_auth_required(realm): """Decorator that protects endpoints using Basic HTTP authentication.""" - def wrapper(fn): + def decorator(fn): @wraps(fn) - def decorated(*args, **kwargs): + def wrapper(*args, **kwargs): if _check_http_auth(): return fn(*args, **kwargs) - header = auth_header or _security.default_http_auth_header - headers = {'WWW-Authenticate': header} + r = _security.default_http_auth_realm if callable(realm) else realm + h = {'WWW-Authenticate': 'Basic realm="%s"' % r} - return _get_unauthorized_response(headers=headers) + return _get_unauthorized_response(headers=h) - return decorated + return wrapper - return wrapper + if callable(realm): + return decorator(realm) + + return decorator def auth_token_required(fn): diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 925b5ba9..1f745801 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -2,6 +2,7 @@ from __future__ import with_statement +import base64 import time try: @@ -130,8 +131,30 @@ def test_token_auth_via_header_invalid_token(self): r = self._get('/token', headers={"X-Auth-Token": 'X'}) self.assertEqual(401, r.status_code) + def test_http_auth(self): + r = self._get('/http', headers={ + 'Authorization': 'Basic ' + base64.b64encode("joe@lp.com:password") + }) + self.assertIn('HTTP Authentication', r.data) -class ConfiguredURLTests(SecurityTest): + def test_invalid_http_auth(self): + r = self._get('/http', headers={ + 'Authorization': 'Basic ' + base64.b64encode("joe@lp.com:bogus") + }) + self.assertIn('

    Unauthorized

    ', r.data) + self.assertIn('WWW-Authenticate', r.headers) + self.assertEquals('Basic realm="Login Required"', r.headers['WWW-Authenticate']) + + def test_custom_http_auth_realm(self): + r = self._get('/http_custom_realm', headers={ + 'Authorization': 'Basic ' + base64.b64encode("joe@lp.com:bogus") + }) + self.assertIn('

    Unauthorized

    ', r.data) + self.assertIn('WWW-Authenticate', r.headers) + self.assertEquals('Basic realm="My Realm"', r.headers['WWW-Authenticate']) + + +class ConfiguredSecurityTests(SecurityTest): AUTH_CONFIG = { 'SECURITY_REGISTERABLE': True, @@ -141,7 +164,8 @@ class ConfiguredURLTests(SecurityTest): 'SECURITY_POST_LOGIN_VIEW': '/post_login', 'SECURITY_POST_LOGOUT_VIEW': '/post_logout', 'SECURITY_POST_REGISTER_VIEW': '/post_register', - 'SECURITY_UNAUTHORIZED_VIEW': '/unauthorized' + 'SECURITY_UNAUTHORIZED_VIEW': '/unauthorized', + 'SECURITY_DEFAULT_HTTP_AUTH_REALM': 'Custom Realm' } def test_login_view(self): @@ -171,6 +195,14 @@ def test_unauthorized(self): msg = 'You are not allowed to access the requested resouce' self.assertIn(msg, r.data) + def test_default_http_auth_realm(self): + r = self._get('/http', headers={ + 'Authorization': 'Basic ' + base64.b64encode("joe@lp.com:bogus") + }) + self.assertIn('

    Unauthorized

    ', r.data) + self.assertIn('WWW-Authenticate', r.headers) + self.assertEquals('Basic realm="Custom Realm"', r.headers['WWW-Authenticate']) + class RegisterableTests(SecurityTest): AUTH_CONFIG = { From e9b49b8e9ea216fca02d8c1c97614a8aa5cba7d2 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 12 Jul 2012 14:25:10 -0400 Subject: [PATCH 083/234] clean up --- flask_security/decorators.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/flask_security/decorators.py b/flask_security/decorators.py index c5a100f0..57506899 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -11,7 +11,7 @@ from functools import wraps -from flask import current_app as app, Response, request, redirect +from flask import current_app, Response, request, redirect from flask.ext.login import login_required, login_url, current_user from flask.ext.principal import RoleNeed, Permission from werkzeug.local import LocalProxy @@ -21,7 +21,9 @@ # Convenient references -_security = LocalProxy(lambda: app.security) +_security = LocalProxy(lambda: current_app.security) + +_logger = LocalProxy(lambda: current_app.logger) _default_unauthorized_txt = """ @@ -45,8 +47,8 @@ def _get_unauthorized_view(): def _check_token(): - header_key = app.security.token_authentication_header - args_key = app.security.token_authentication_key + header_key = _security.token_authentication_header + args_key = _security.token_authentication_key header_token = request.headers.get(header_key, None) token = request.args.get(args_key, header_token) @@ -55,7 +57,7 @@ def _check_token(): try: data = serializer.loads(token) - user = app.security.datastore.find_user(id=data[0], + user = _security.datastore.find_user(id=data[0], authentication_token=token) if data[1] != utils.md5(user.email): @@ -71,11 +73,11 @@ def _check_http_auth(): auth = request.authorization or dict(username=None, password=None) try: - user = app.security.datastore.find_user(email=auth.username) + user = _security.datastore.find_user(email=auth.username) except UserNotFoundError: return False - return app.security.pwd_context.verify(auth.password, user.password) + return _security.pwd_context.verify(auth.password, user.password) def http_auth_required(realm): @@ -102,6 +104,7 @@ def wrapper(*args, **kwargs): def auth_token_required(fn): """Decorator that protects endpoints using token authentication.""" + @wraps(fn) def decorated(*args, **kwargs): if _check_token(): @@ -135,8 +138,8 @@ def decorated_view(*args, **kwargs): for perm in perms: if not perm.can(): - app.logger.debug('Identity does not provide the ' - 'roles: %s' % [r for r in roles]) + _logger.debug('Identity does not provide the ' + 'roles: %s' % [r for r in roles]) return _get_unauthorized_view() @@ -174,8 +177,8 @@ def decorated_view(*args, **kwargs): r1 = [r for r in roles] r2 = [r.name for r in current_user.roles] - app.logger.debug('Current user does not provide a ' - 'required role. Accepted: %s Provided: %s' % (r1, r2)) + _logger.debug('Current user does not provide a required role. ' + 'Accepted: %s Provided: %s' % (r1, r2)) return _get_unauthorized_view() From aba98a3a037bc28b61bb8a502f47d412f02b5d80 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 12 Jul 2012 14:25:44 -0400 Subject: [PATCH 084/234] clean up --- 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 57506899..7a68f6ac 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -58,7 +58,7 @@ def _check_token(): try: data = serializer.loads(token) user = _security.datastore.find_user(id=data[0], - authentication_token=token) + authentication_token=token) if data[1] != utils.md5(user.email): raise Exception() From ed8082868fe918ddcf62cd26edb5103814f898bd Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 12 Jul 2012 14:40:20 -0400 Subject: [PATCH 085/234] Simplify exceptions --- flask_security/confirmable.py | 11 ++++---- flask_security/exceptions.py | 6 ----- flask_security/recoverable.py | 12 ++++----- flask_security/views.py | 49 ++++++++++++++--------------------- 4 files changed, 31 insertions(+), 47 deletions(-) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 05d0d3a1..4ef33bd8 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -15,7 +15,7 @@ from flask import current_app as app, request, url_for from werkzeug.local import LocalProxy -from .exceptions import UserNotFoundError, ConfirmationError, TokenExpiredError +from .exceptions import UserNotFoundError, ConfirmationError from .utils import send_mail, get_max_age, md5 from .signals import user_confirmed, confirm_instructions_sent @@ -96,16 +96,17 @@ def confirm_by_token(token): return user - except UserNotFoundError: - raise ConfirmationError('Invalid confirmation token') - except SignatureExpired: sig_okay, data = serializer.loads_unsafe(token) - raise TokenExpiredError(user=_datastore.find_user(id=data[0])) + raise ConfirmationError('Confirmation token expired', + user=_datastore.find_user(id=data[0])) except BadSignature: raise ConfirmationError('Invalid confirmation token') + except UserNotFoundError: + raise ConfirmationError('Invalid confirmation token') + def reset_confirmation_token(user): """Resets the specified user's confirmation token and sends the user diff --git a/flask_security/exceptions.py b/flask_security/exceptions.py index a060d8e3..8e504b78 100644 --- a/flask_security/exceptions.py +++ b/flask_security/exceptions.py @@ -66,12 +66,6 @@ class ConfirmationError(SecurityError): """ -class TokenExpiredError(SecurityError): - """Raised when a user attempts to confirm their email but their token - has expired - """ - - class ConfirmationRequiredError(SecurityError): """Raised when a user attempts to login but requires confirmation """ diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index ee2144d1..0cb70b02 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -13,8 +13,7 @@ from flask import current_app as app, request, url_for from werkzeug.local import LocalProxy -from .exceptions import ResetPasswordError, UserNotFoundError, \ - TokenExpiredError +from .exceptions import ResetPasswordError, UserNotFoundError from .signals import password_reset, password_reset_requested, \ reset_instructions_sent from .utils import send_mail, get_max_age, md5 @@ -95,16 +94,17 @@ def reset_by_token(token, password): return user - except UserNotFoundError: - raise ResetPasswordError('Invalid reset password token') - except SignatureExpired: sig_okay, data = serializer.loads_unsafe(token) - raise TokenExpiredError(user=_datastore.find_user(id=data[0])) + raise ResetPasswordError('Password reset token expired', + user=_datastore.find_user(id=data[0])) except BadSignature: raise ResetPasswordError('Invalid reset password token') + except UserNotFoundError: + raise ResetPasswordError('Invalid reset password token') + def reset_password_reset_token(user): """Resets the specified user's reset password token and sends the user diff --git a/flask_security/views.py b/flask_security/views.py index a4567277..690a50be 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -19,8 +19,8 @@ from werkzeug.local import LocalProxy from .confirmable import confirm_by_token, reset_confirmation_token -from .exceptions import TokenExpiredError, ConfirmationError, \ - BadCredentialsError, ResetPasswordError +from .exceptions import ConfirmationError, BadCredentialsError, \ + ResetPasswordError from .forms import LoginForm, RegisterForm, ForgotPasswordForm, \ ResetPasswordForm, ResendConfirmationForm from .recoverable import reset_by_token, \ @@ -194,40 +194,28 @@ def send_confirmation(): def confirm_account(token): """View function which handles a account confirmation request.""" - error = False - try: user = confirm_by_token(token) + _logger.debug('%s confirmed their account' % user) except ConfirmationError, e: - error = True - - _logger.debug('Confirmation error: ' + str(e)) - - do_flash(str(e), 'error') - - except TokenExpiredError, e: - error = True - - reset_confirmation_token(e.user) + msg = str(e) - msg = 'You did not confirm your email within %s. ' \ - 'A new confirmation code has been sent to %s' % ( - _security.confirm_email_within, e.user.email) + _logger.debug('Confirmation error: ' + msg) - _logger.debug('Attempted account confirmation but token was expired') + if e.user: + reset_confirmation_token(e.user) + msg = ('You did not confirm your email within %s. ' + 'A new confirmation code has been sent to %s' % ( + _security.confirm_email_within, e.user.email)) do_flash(msg, 'error') - if error: return redirect(get_url(_security.confirm_error_view)) - _logger.debug('User %s confirmed' % user) - do_flash('Your email has been confirmed. You may now log in.', 'success') - return redirect(_security.post_confirm_view or - _security.post_login_view) + return redirect(_security.post_confirm_view or _security.post_login_view) def forgot_password(): @@ -265,18 +253,19 @@ def reset_password(token): if form.validate_on_submit(): try: - reset_by_token(token=token, **form.to_dict()) + user = reset_by_token(token=token, **form.to_dict()) + _logger.debug('%s reset their password' % user) except ResetPasswordError, e: - _logger.debug('Password reset error: ' + str(e)) + msg = str(e) - do_flash(str(e), 'error') + _logger.debug('Password reset error: ' + msg) - except TokenExpiredError, e: - _logger.debug('Attempted password reset but token was expired') + if e.user: + msg = ('You did not reset your password within ' + '%s.' % _security.reset_password_within) - do_flash('You did not reset your password within ' - '%s.' % _security.reset_password_within) + do_flash(msg, 'error') return render_template('security/passwords/edit.html', reset_password_form=form, From 3df4c4d14a90a9d64145737483978a95dfdf9aa7 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 12 Jul 2012 14:41:54 -0400 Subject: [PATCH 086/234] Clean up --- flask_security/exceptions.py | 5 ----- flask_security/views.py | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/flask_security/exceptions.py b/flask_security/exceptions.py index 8e504b78..6802c2cf 100644 --- a/flask_security/exceptions.py +++ b/flask_security/exceptions.py @@ -66,11 +66,6 @@ class ConfirmationError(SecurityError): """ -class ConfirmationRequiredError(SecurityError): - """Raised when a user attempts to login but requires confirmation - """ - - class ResetPasswordError(SecurityError): """Raised when a password reset error occurs """ diff --git a/flask_security/views.py b/flask_security/views.py index 690a50be..74530e0b 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -112,7 +112,7 @@ def authenticate(): return redirect(get_post_login_redirect()) - raise BadCredentialsError('Inactive user') + raise BadCredentialsError('Account is disabled') except BadCredentialsError, e: msg = str(e) From 18c7a838b0958054a06ceaf5f74a6a9bcbaa0bda Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 12 Jul 2012 15:24:57 -0400 Subject: [PATCH 087/234] Make most messages configurable --- flask_security/confirmable.py | 8 ++++---- flask_security/core.py | 16 ++++++++++++++++ flask_security/decorators.py | 8 ++++---- flask_security/recoverable.py | 6 +++--- flask_security/utils.py | 4 ++++ flask_security/views.py | 25 +++++++++++++++---------- tests/functional_tests.py | 8 ++++---- 7 files changed, 50 insertions(+), 25 deletions(-) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 4ef33bd8..58ceff7c 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -16,7 +16,7 @@ from werkzeug.local import LocalProxy from .exceptions import UserNotFoundError, ConfirmationError -from .utils import send_mail, get_max_age, md5 +from .utils import send_mail, get_max_age, md5, get_message from .signals import user_confirmed, confirm_instructions_sent @@ -87,7 +87,7 @@ def confirm_by_token(token): raise UserNotFoundError() if user.confirmed_at: - raise ConfirmationError('Account has already been confirmed') + raise ConfirmationError(get_message('ALREADY_CONFIRMED')) user.confirmed_at = datetime.utcnow() _datastore._save_model(user) @@ -102,10 +102,10 @@ def confirm_by_token(token): user=_datastore.find_user(id=data[0])) except BadSignature: - raise ConfirmationError('Invalid confirmation token') + raise ConfirmationError(get_message('INVALID_CONFIRMATION_TOKEN')) except UserNotFoundError: - raise ConfirmationError('Invalid confirmation token') + raise ConfirmationError(get_message('INVALID_CONFIRMATION_TOKEN')) def reset_confirmation_token(user): diff --git a/flask_security/core.py b/flask_security/core.py index b12fb1d4..c0e65b0d 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -60,6 +60,19 @@ 'DEFAULT_HTTP_AUTH_REALM': 'Login Required' } +#: Default Flask-Security flash messages +_default_flash_messages = { + 'UNAUTHORIZED': 'You do not have permission to view this resource.', + 'ACCOUNT_CONFIRMED': 'Your account has been confirmed. You may now log in.', + 'ALREADY_CONFIRMED': 'Your account has already been confirmed', + 'INVALID_CONFIRMATION_TOKEN': 'Invalid confirmation token', + 'PASSWORD_RESET_REQUEST': 'Instructions to reset your password have been sent to %(email)s.', + 'PASSWORD_RESET_EXPIRED': 'You did not reset your password within %(within)s. New instructions have been sent to %(email)s.', + 'INVALID_RESET_PASSWORD_TOKEN': 'Invalid reset password token', + 'CONFIRMATION_REQUEST': 'A new confirmation code has been sent to %(email)s.', + 'CONFIRMATION_EXPIRED': 'You did not confirm your account within %(within)s. New instructions to confirm your account have been sent to %(email)s.' +} + def _user_loader(user_id): try: @@ -233,6 +246,9 @@ def init_app(self, app, datastore): for key, value in _default_config.items(): app.config.setdefault('SECURITY_' + key, value) + for key, value in _default_flash_messages.items(): + app.config.setdefault('SECURITY_MSG_' + key, value) + self.datastore = datastore self.auth_provider = AuthenticationProvider() self.login_manager = _get_login_manager(app) diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 7a68f6ac..481f075e 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -26,7 +26,7 @@ _logger = LocalProxy(lambda: current_app.logger) -_default_unauthorized_txt = """ +_default_unauthorized_html = """

    Unauthorized

    The server could not verify that you are authorized to access the URL requested. You either supplied the wrong credentials (e.g. a bad password), @@ -35,14 +35,14 @@ def _get_unauthorized_response(text=None, headers=None): - text = text or _default_unauthorized_txt + text = text or _default_unauthorized_html headers = headers or {} - return Response(_default_unauthorized_txt, 401, headers) + return Response(text, 401, headers) def _get_unauthorized_view(): cv = utils.get_url(utils.config_value('UNAUTHORIZED_VIEW')) - utils.do_flash('You do not have permission to view this resource', 'error') + utils.do_flash(utils.get_message('UNAUTHORIZED'), 'error') return redirect(cv or request.referrer or '/') diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 0cb70b02..cebd57e9 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -16,7 +16,7 @@ from .exceptions import ResetPasswordError, UserNotFoundError from .signals import password_reset, password_reset_requested, \ reset_instructions_sent -from .utils import send_mail, get_max_age, md5 +from .utils import send_mail, get_max_age, md5, get_message # Convenient references @@ -100,10 +100,10 @@ def reset_by_token(token, password): user=_datastore.find_user(id=data[0])) except BadSignature: - raise ResetPasswordError('Invalid reset password token') + raise ResetPasswordError(get_message('INVALID_RESET_PASSWORD_TOKEN')) except UserNotFoundError: - raise ResetPasswordError('Invalid reset password token') + raise ResetPasswordError(get_message('INVALID_RESET_PASSWORD_TOKEN')) def reset_password_reset_token(user): diff --git a/flask_security/utils.py b/flask_security/utils.py index 662563e0..9d537c3e 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -95,6 +95,10 @@ def strip_prefix(tup): return dict([strip_prefix(i) for i in items if i[0].startswith(prefix)]) +def get_message(key, **kwargs): + return config_value('MSG_' + key) % kwargs + + def config_value(key, app=None, default=None): """Get a Flask-Security configuration value. diff --git a/flask_security/views.py b/flask_security/views.py index 74530e0b..b398f7d9 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -28,7 +28,7 @@ from .signals import user_registered from .tokens import generate_authentication_token from .utils import get_url, get_post_login_redirect, do_flash, \ - get_remember_token + get_remember_token, get_message # Convenient references @@ -181,7 +181,8 @@ def send_confirmation(): _logger.debug('%s request confirmation instructions' % user) - msg = 'A new confirmation code has been sent to ' + user.email + msg = get_message('CONFIRMATION_REQUEST', email=user.email) + do_flash(msg, 'info') else: @@ -205,15 +206,16 @@ def confirm_account(token): if e.user: reset_confirmation_token(e.user) - msg = ('You did not confirm your email within %s. ' - 'A new confirmation code has been sent to %s' % ( - _security.confirm_email_within, e.user.email)) + + msg = get_message('CONFIRMATION_EXPIRED', + within=_security.confirm_email_within, + email=e.user.email) do_flash(msg, 'error') return redirect(get_url(_security.confirm_error_view)) - do_flash('Your email has been confirmed. You may now log in.', 'success') + do_flash(get_message('ACCOUNT_CONFIRMED'), 'success') return redirect(_security.post_confirm_view or _security.post_login_view) @@ -230,8 +232,8 @@ def forgot_password(): _logger.debug('%s requested to reset their password' % user) - do_flash('Instructions to reset your password have been ' - 'sent to %s' % user.email, 'success') + msg = get_message('PASSWORD_RESET_REQUEST', email=user.email) + do_flash(msg, 'success') return redirect(_security.post_forgot_view) @@ -262,8 +264,11 @@ def reset_password(token): _logger.debug('Password reset error: ' + msg) if e.user: - msg = ('You did not reset your password within ' - '%s.' % _security.reset_password_within) + reset_password_reset_token(e.user) + + msg = get_message('PASSWORD_RESET_EXPIRED', + within=_security.reset_password_within, + email=e.user.email) do_flash(msg, 'error') diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 1f745801..01dfa959 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -49,7 +49,7 @@ def test_bad_password(self): def test_inactive_user(self): r = self.authenticate("tiya@lp.com", "password") - self.assertIn("Inactive user", r.data) + self.assertIn("Account is disabled", r.data) def test_logout(self): self.authenticate() @@ -237,7 +237,7 @@ def test_confirm_email(self): token = registrations[0]['confirm_token'] r = self.client.get('/confirm/' + token, follow_redirects=True) - self.assertIn('Your email has been confirmed. You may now log in.', r.data) + self.assertIn('Your account has been confirmed. You may now log in.', r.data) def test_confirm_email_twice_flashes_already_confirmed_message(self): e = 'dude@lp.com' @@ -249,7 +249,7 @@ def test_confirm_email_twice_flashes_already_confirmed_message(self): url = '/confirm/' + token self.client.get(url, follow_redirects=True) r = self.client.get(url, follow_redirects=True) - self.assertIn('Account has already been confirmed', r.data) + self.assertIn('Your account has already been confirmed', r.data) def test_invalid_token_when_confirming_email(self): r = self.client.get('/confirm/bogus', follow_redirects=True) @@ -280,7 +280,7 @@ def test_expired_confirmation_token_sends_email(self): self.assertNotIn(token, outbox[0].html) expire_text = self.app.security.confirm_email_within - text = 'You did not confirm your email within %s' % expire_text + text = 'You did not confirm your account within %s' % expire_text self.assertIn(text, r.data) From 1d86d33b0b31c37a6df68f4219843aec4ac777e2 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 12 Jul 2012 15:39:35 -0400 Subject: [PATCH 088/234] Add category for messages --- flask_security/core.py | 18 +++++++++--------- flask_security/decorators.py | 2 +- flask_security/utils.py | 3 ++- flask_security/views.py | 31 ++++++++++++++++--------------- 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index c0e65b0d..b9421c16 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -62,15 +62,15 @@ #: Default Flask-Security flash messages _default_flash_messages = { - 'UNAUTHORIZED': 'You do not have permission to view this resource.', - 'ACCOUNT_CONFIRMED': 'Your account has been confirmed. You may now log in.', - 'ALREADY_CONFIRMED': 'Your account has already been confirmed', - 'INVALID_CONFIRMATION_TOKEN': 'Invalid confirmation token', - 'PASSWORD_RESET_REQUEST': 'Instructions to reset your password have been sent to %(email)s.', - 'PASSWORD_RESET_EXPIRED': 'You did not reset your password within %(within)s. New instructions have been sent to %(email)s.', - 'INVALID_RESET_PASSWORD_TOKEN': 'Invalid reset password token', - 'CONFIRMATION_REQUEST': 'A new confirmation code has been sent to %(email)s.', - 'CONFIRMATION_EXPIRED': 'You did not confirm your account within %(within)s. New instructions to confirm your account have been sent to %(email)s.' + 'UNAUTHORIZED': ('You do not have permission to view this resource.', 'error'), + 'ACCOUNT_CONFIRMED': ('Your account has been confirmed. You may now log in.', 'success'), + 'ALREADY_CONFIRMED': ('Your account has already been confirmed', 'info'), + 'INVALID_CONFIRMATION_TOKEN': ('Invalid confirmation token', 'error'), + 'PASSWORD_RESET_REQUEST': ('Instructions to reset your password have been sent to %(email)s.', 'info'), + 'PASSWORD_RESET_EXPIRED': ('You did not reset your password within %(within)s. New instructions have been sent to %(email)s.', 'error'), + 'INVALID_RESET_PASSWORD_TOKEN': ('Invalid reset password token', 'error'), + 'CONFIRMATION_REQUEST': ('A new confirmation code has been sent to %(email)s.', 'info'), + 'CONFIRMATION_EXPIRED': ('You did not confirm your account within %(within)s. New instructions to confirm your account have been sent to %(email)s.', 'error') } diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 481f075e..cfdb4922 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -42,7 +42,7 @@ def _get_unauthorized_response(text=None, headers=None): def _get_unauthorized_view(): cv = utils.get_url(utils.config_value('UNAUTHORIZED_VIEW')) - utils.do_flash(utils.get_message('UNAUTHORIZED'), 'error') + utils.do_flash(utils.get_message('UNAUTHORIZED')) return redirect(cv or request.referrer or '/') diff --git a/flask_security/utils.py b/flask_security/utils.py index 9d537c3e..23a71602 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -96,7 +96,8 @@ def strip_prefix(tup): def get_message(key, **kwargs): - return config_value('MSG_' + key) % kwargs + rv = config_value('MSG_' + key) + return rv[0] % kwargs, rv[1] def config_value(key, app=None, default=None): diff --git a/flask_security/views.py b/flask_security/views.py index b398f7d9..d98536d8 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -181,9 +181,9 @@ def send_confirmation(): _logger.debug('%s request confirmation instructions' % user) - msg = get_message('CONFIRMATION_REQUEST', email=user.email) + msg, cat = get_message('CONFIRMATION_REQUEST', email=user.email) - do_flash(msg, 'info') + do_flash(msg, cat) else: for key, value in form.errors.items(): @@ -200,22 +200,22 @@ def confirm_account(token): _logger.debug('%s confirmed their account' % user) except ConfirmationError, e: - msg = str(e) + msg, cat = str(e), 'error' _logger.debug('Confirmation error: ' + msg) if e.user: reset_confirmation_token(e.user) - msg = get_message('CONFIRMATION_EXPIRED', - within=_security.confirm_email_within, - email=e.user.email) + msg, cat = get_message('CONFIRMATION_EXPIRED', + within=_security.confirm_email_within, + email=e.user.email) - do_flash(msg, 'error') + do_flash(msg, cat) return redirect(get_url(_security.confirm_error_view)) - do_flash(get_message('ACCOUNT_CONFIRMED'), 'success') + do_flash(get_message('ACCOUNT_CONFIRMED')) return redirect(_security.post_confirm_view or _security.post_login_view) @@ -232,8 +232,9 @@ def forgot_password(): _logger.debug('%s requested to reset their password' % user) - msg = get_message('PASSWORD_RESET_REQUEST', email=user.email) - do_flash(msg, 'success') + msg, cat = get_message('PASSWORD_RESET_REQUEST', email=user.email) + + do_flash(msg, cat) return redirect(_security.post_forgot_view) @@ -259,18 +260,18 @@ def reset_password(token): _logger.debug('%s reset their password' % user) except ResetPasswordError, e: - msg = str(e) + msg, cat = str(e), 'error' _logger.debug('Password reset error: ' + msg) if e.user: reset_password_reset_token(e.user) - msg = get_message('PASSWORD_RESET_EXPIRED', - within=_security.reset_password_within, - email=e.user.email) + msg, cat = get_message('PASSWORD_RESET_EXPIRED', + within=_security.reset_password_within, + email=e.user.email) - do_flash(msg, 'error') + do_flash(msg, cat) return render_template('security/passwords/edit.html', reset_password_form=form, From cc9832653ab1e2d3ec0b4e178f66348ef80b3af5 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 13 Jul 2012 10:40:07 -0400 Subject: [PATCH 089/234] See if pypy passes --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index de5567fe..15579352 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,7 @@ python: - "2.5" - "2.6" - "2.7" + - "pypy" install: - pip install . --quiet --use-mirrors From 3cd20f0e3371840023dfbac8b096862709a3c67f Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 13 Jul 2012 12:02:43 -0400 Subject: [PATCH 090/234] Clean up --- flask_security/core.py | 47 ++++++------------------------------- flask_security/datastore.py | 2 +- flask_security/utils.py | 3 +-- flask_security/views.py | 39 ++++++++++++++++++++++++++++-- 4 files changed, 46 insertions(+), 45 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index b9421c16..8050cc1e 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -10,7 +10,7 @@ """ from itsdangerous import URLSafeTimedSerializer -from flask import current_app, Blueprint +from flask import current_app from flask.ext.login import AnonymousUser as AnonymousUserBase, \ UserMixin as BaseUserMixin, LoginManager, current_user from flask.ext.principal import Principal, RoleNeed, UserNeed, Identity, \ @@ -20,7 +20,6 @@ from . import views, exceptions from .confirmable import requires_confirmation -from .decorators import login_required from .utils import config_value as cv, get_config @@ -144,40 +143,6 @@ def _get_token_auth_serializer(app): return _get_serializer(app, app.config['SECURITY_AUTH_SALT']) -def _create_blueprint(app): - bp = Blueprint('flask_security', __name__, template_folder='templates') - - bp.route(cv('AUTH_URL', app=app), - methods=['POST'], - endpoint='authenticate')(views.authenticate) - - bp.route(cv('LOGOUT_URL', app=app), - endpoint='logout')(login_required(views.logout)) - - if cv('REGISTERABLE', app=app): - bp.route(cv('REGISTER_URL', app=app), - methods=['GET', 'POST'], - endpoint='register')(views.register_user) - - if cv('RECOVERABLE', app=app): - bp.route(cv('RESET_URL', app=app), - methods=['GET', 'POST'], - endpoint='forgot_password')(views.forgot_password) - bp.route(cv('RESET_URL', app=app) + '/', - methods=['GET', 'POST'], - endpoint='reset_password')(views.reset_password) - - if cv('CONFIRMABLE', app=app): - bp.route(cv('CONFIRM_URL', app=app), - methods=['GET', 'POST'], - endpoint='send_confirmation')(views.send_confirmation) - bp.route(cv('CONFIRM_URL', app=app) + '/', - methods=['GET', 'POST'], - endpoint='confirm_account')(views.confirm_account) - - return bp - - class RoleMixin(object): """Mixin for `Role` model definitions""" def __eq__(self, other): @@ -233,7 +198,7 @@ class Security(object): def __init__(self, app=None, datastore=None, **kwargs): self.init_app(app, datastore, **kwargs) - def init_app(self, app, datastore): + def init_app(self, app, datastore, register_blueprint=True): """Initializes the Flask-Security extension for the specified application and datastore implentation. @@ -263,9 +228,11 @@ def init_app(self, app, datastore): identity_loaded.connect_via(app)(_on_identity_loaded) - bp = _create_blueprint(app) - pre = cv('URL_PREFIX', app=app) - app.register_blueprint(bp, url_prefix=pre) + if register_blueprint: + bp = views.create_blueprint(app, 'flask_security', __name__, + template_folder='templates', + url_prefix=cv('URL_PREFIX', app=app)) + app.register_blueprint(bp) app.security = self diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 3ba44475..bd076f5f 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -11,7 +11,7 @@ from flask import current_app -from . import exceptions, confirmable, utils +from . import exceptions, utils class UserDatastore(object): diff --git a/flask_security/utils.py b/flask_security/utils.py index 23a71602..62274baf 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -15,8 +15,7 @@ from contextlib import contextmanager from datetime import datetime, timedelta -from flask import url_for, flash, current_app, request, session, \ - render_template +from flask import url_for, flash, current_app, request, session, render_template from flask.ext.login import make_secure_token from .signals import user_registered, password_reset_requested diff --git a/flask_security/views.py b/flask_security/views.py index d98536d8..4ea617c0 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -12,13 +12,14 @@ from datetime import datetime from flask import current_app as app, redirect, request, session, \ - render_template, jsonify + render_template, jsonify, Blueprint from flask.ext.login import login_user, logout_user from flask.ext.principal import Identity, AnonymousIdentity, identity_changed from werkzeug.datastructures import MultiDict from werkzeug.local import LocalProxy from .confirmable import confirm_by_token, reset_confirmation_token +from .decorators import login_required from .exceptions import ConfirmationError, BadCredentialsError, \ ResetPasswordError from .forms import LoginForm, RegisterForm, ForgotPasswordForm, \ @@ -28,7 +29,7 @@ from .signals import user_registered from .tokens import generate_authentication_token from .utils import get_url, get_post_login_redirect, do_flash, \ - get_remember_token, get_message + get_remember_token, get_message, config_value # Convenient references @@ -276,3 +277,37 @@ def reset_password(token): return render_template('security/passwords/edit.html', reset_password_form=form, password_reset_token=token) + + +def create_blueprint(app, name, import_name, **kwargs): + bp = Blueprint(name, import_name, **kwargs) + + bp.route(config_value('AUTH_URL', app=app), + methods=['POST'], + endpoint='authenticate')(authenticate) + + bp.route(config_value('LOGOUT_URL', app=app), + endpoint='logout')(login_required(logout)) + + if config_value('REGISTERABLE', app=app): + bp.route(config_value('REGISTER_URL', app=app), + methods=['GET', 'POST'], + endpoint='register')(register_user) + + if config_value('RECOVERABLE', app=app): + bp.route(config_value('RESET_URL', app=app), + methods=['GET', 'POST'], + endpoint='forgot_password')(forgot_password) + bp.route(config_value('RESET_URL', app=app) + '/', + methods=['GET', 'POST'], + endpoint='reset_password')(reset_password) + + if config_value('CONFIRMABLE', app=app): + bp.route(config_value('CONFIRM_URL', app=app), + methods=['GET', 'POST'], + endpoint='send_confirmation')(send_confirmation) + bp.route(config_value('CONFIRM_URL', app=app) + '/', + methods=['GET', 'POST'], + endpoint='confirm_account')(confirm_account) + + return bp From 7b624b86228a91ce0ee0d8f03ed1d2f5cafe4132 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 13 Jul 2012 12:23:48 -0400 Subject: [PATCH 091/234] Bring back scripts --- flask_security/script.py | 148 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 flask_security/script.py diff --git a/flask_security/script.py b/flask_security/script.py new file mode 100644 index 00000000..8a5e425c --- /dev/null +++ b/flask_security/script.py @@ -0,0 +1,148 @@ + +try: + import simplejson as json +except ImportError: + import json + +import inspect +import os +import re + +from flask import current_app +from flask.ext.script import Command, Option, prompt_bool +from werkzeug.local import LocalProxy + +from flask_security import views + + +_datastore = LocalProxy(lambda: current_app.security.datastore) + + +def pprint(obj): + print json.dumps(obj, sort_keys=True, indent=4) + + +class CreateUserCommand(Command): + """Create a user""" + + option_list = ( + Option('-e', '--email', dest='email', default=None), + Option('-p', '--password', dest='password', default=None), + Option('-a', '--active', dest='active', default=''), + Option('-r', '--roles', dest='roles', default=''), + ) + + def run(self, **kwargs): + # sanitize active input + ai = re.sub(r'\s', '', str(kwargs['active'])) + kwargs['active'] = ai.lower() in ['', 'y', 'yes', '1', 'active'] + + # sanitize role input a bit + ri = re.sub(r'\s', '', kwargs['roles']) + kwargs['roles'] = [] if ri == '' else ri.split(',') + + _datastore.create_user(**kwargs) + + print 'User created successfully.' + kwargs['password'] = '****' + pprint(kwargs) + + +class CreateRoleCommand(Command): + """Create a role""" + + option_list = ( + Option('-n', '--name', dest='name', default=None), + Option('-d', '--desc', dest='description', default=None), + ) + + def run(self, **kwargs): + _datastore.create_role(**kwargs) + print 'Role "%(name)s" created successfully.' % kwargs + + +class _RoleCommand(Command): + option_list = ( + Option('-u', '--user', dest='user_identifier'), + Option('-r', '--role', dest='role_name'), + ) + + +class AddRoleCommand(_RoleCommand): + """Add a role to a user""" + + def run(self, user_identifier, role_name): + _datastore.add_role_to_user(user_identifier, role_name) + print "Role '%s' added to user '%s' successfully" % (role_name, user_identifier) + + +class RemoveRoleCommand(_RoleCommand): + """Add a role to a user""" + + def run(self, user_identifier, role_name): + _datastore.remove_role_from_user(user_identifier, role_name) + print "Role '%s' removed from user '%s' successfully" % (role_name, user_identifier) + + +class _ToggleActiveCommand(Command): + option_list = ( + Option('-u', '--user', dest='user_identifier'), + ) + + +class DeactivateUserCommand(_ToggleActiveCommand): + """Deactive a user""" + + def run(self, user_identifier): + _datastore.deactivate_user(user_identifier) + print "User '%s' has been deactivated" % user_identifier + + +class ActivateUserCommand(_ToggleActiveCommand): + """Deactive a user""" + + def run(self, user_identifier): + _datastore.activate_user(user_identifier) + print "User '%s' has been activated" % user_identifier + + +class GenerateBlueprintCommand(Command): + """Generate a Flask-Security blueprint object""" + + option_list = ( + Option('--output', '-o', dest='output', default=None), + ) + + def run(self, output): + output = os.path.join(os.getcwd(), output) if output else 'security.py' + + if os.path.exists(output): + msg = 'File %s exists. Do you want to overwrite it?' % output + if not prompt_bool(msg): + return + + with open(output, 'w') as o: + source = inspect.getfile(views).replace('.pyc', '.py') + + with open(source, 'r') as s: + to_remove = '"""' + views.__doc__ + '"""' + to_replace = """ +\""" + Flask-Security + ~~~~~~~~~~~~~~ + + This module was generated by Flask-Security to give developers greater + control over the various security mechanisms. For more information about + using this feature see: + + TODO: Documentation URL +\""" +""" + ctx = dict(module_name=inspect.getmodulename(output)) + to_replace = to_replace % ctx + contents = s.read().replace(to_remove, to_replace) + + o.write(contents) + + print 'File generated successfully.' + print output From 43375344373919350b8792e1c514da260d9c8848 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 13 Jul 2012 12:24:51 -0400 Subject: [PATCH 092/234] Script usage additions --- example/app.py | 12 +- example/manage.py | 16 +- example/security.py | 316 +++++++++++++++++++++++++++++++++++++ flask_security/__init__.py | 1 + flask_security/views.py | 16 +- 5 files changed, 341 insertions(+), 20 deletions(-) create mode 100644 example/security.py diff --git a/example/app.py b/example/app.py index a649e3b2..2e20bf4c 100644 --- a/example/app.py +++ b/example/app.py @@ -118,7 +118,7 @@ def unauthorized(): return app -def create_sqlalchemy_app(auth_config=None): +def create_sqlalchemy_app(auth_config=None, register_blueprint=True): app = create_app(auth_config) app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://root@localhost/flask_security_test' @@ -149,7 +149,13 @@ class User(db.Model, UserMixin): roles = db.relationship('Role', secondary=roles_users, backref=db.backref('users', lazy='dynamic')) - Security(app, SQLAlchemyUserDatastore(db, User, Role)) + Security(app, SQLAlchemyUserDatastore(db, User, Role), + register_blueprint=register_blueprint) + + if not register_blueprint: + from example import security + blueprint = security.create_blueprint(app, 'flask_security', __name__) + app.register_blueprint(blueprint) @app.before_first_request def before_first_request(): @@ -197,6 +203,6 @@ def before_first_request(): return app if __name__ == '__main__': - app = create_sqlalchemy_app() + app = create_sqlalchemy_app(register_blueprint=False) #app = create_mongoengine_app() app.run() diff --git a/example/manage.py b/example/manage.py index 15977bb3..ea5ff32c 100644 --- a/example/manage.py +++ b/example/manage.py @@ -1,21 +1,19 @@ # a little trick so you can run: -# $ python example/app.py +# $ python example/app.py # from the root of the security project -import sys, os +import sys +import os + sys.path.pop(0) sys.path.insert(0, os.getcwd()) from example import app from flask.ext.script import Manager -from flask.ext.security.script import (CreateUserCommand , AddRoleCommand, - RemoveRoleCommand, ActivateUserCommand, DeactivateUserCommand) +from flask.ext.security.script import CreateUserCommand, GenerateBlueprintCommand manager = Manager(app.create_sqlalchemy_app()) manager.add_command('create_user', CreateUserCommand()) -manager.add_command('add_role', AddRoleCommand()) -manager.add_command('remove_role', RemoveRoleCommand()) -manager.add_command('deactivate_user', DeactivateUserCommand()) -manager.add_command('activate_user', ActivateUserCommand()) +manager.add_command('generate_blueprint', GenerateBlueprintCommand()) if __name__ == "__main__": - manager.run() \ No newline at end of file + manager.run() diff --git a/example/security.py b/example/security.py new file mode 100644 index 00000000..fe684d87 --- /dev/null +++ b/example/security.py @@ -0,0 +1,316 @@ +# -*- coding: utf-8 -*- + +""" + Flask-Security + ~~~~~~~~~~~~~~ + + This module was generated by Flask-Security to give developers greater + control over the various security mechanisms. For more information about + using this feature see: + + TODO: Documentation URL +""" + + +from datetime import datetime + +from flask import current_app as app, redirect, request, session, \ + render_template, jsonify, Blueprint +from flask.ext.login import login_user, logout_user +from flask.ext.principal import Identity, AnonymousIdentity, identity_changed +from werkzeug.datastructures import MultiDict +from werkzeug.local import LocalProxy + +from flask_security.confirmable import confirm_by_token, reset_confirmation_token +from flask_security.decorators import login_required +from flask_security.exceptions import ConfirmationError, BadCredentialsError, \ + ResetPasswordError +from flask_security.forms import LoginForm, RegisterForm, ForgotPasswordForm, \ + ResetPasswordForm, ResendConfirmationForm +from flask_security.recoverable import reset_by_token, \ + reset_password_reset_token +from flask_security.signals import user_registered +from flask_security.tokens import generate_authentication_token +from flask_security.utils import get_url, get_post_login_redirect, do_flash, \ + get_remember_token, get_message, config_value + + +# Convenient references +_security = LocalProxy(lambda: app.security) + +_datastore = LocalProxy(lambda: app.security.datastore) + +_logger = LocalProxy(lambda: app.logger) + + +def _do_login(user, remember=True): + """Performs the login and sends the appropriate signal.""" + + if not login_user(user, remember): + return False + + if user.authentication_token is None: + user.authentication_token = generate_authentication_token(user) + + if remember: + user.remember_token = get_remember_token(user.email, user.password) + + if _security.trackable: + old_current, new_current = user.current_login_at, datetime.utcnow() + user.last_login_at = old_current or new_current + user.current_login_at = new_current + + old_current, new_current = user.current_login_ip, request.remote_addr + user.last_login_ip = old_current or new_current + user.current_login_ip = new_current + + user.login_count = user.login_count + 1 if user.login_count else 0 + + _datastore._save_model(user) + + identity_changed.send(app._get_current_object(), + identity=Identity(user.id)) + + _logger.debug('User %s logged in' % user) + return True + + +def _json_auth_ok(user): + return jsonify({ + "meta": { + "code": 200 + }, + "response": { + "user": { + "id": str(user.id), + "authentication_token": user.authentication_token + } + } + }) + + +def _json_auth_error(msg): + resp = jsonify({ + "meta": { + "code": 400 + }, + "response": { + "error": msg + } + }) + resp.status_code = 400 + return resp + + +def authenticate(): + """View function which handles an authentication request.""" + + form = LoginForm(MultiDict(request.json) if request.json else request.form) + + try: + user = _security.auth_provider.authenticate(form) + + if _do_login(user, remember=form.remember.data): + if request.json: + return _json_auth_ok(user) + + return redirect(get_post_login_redirect()) + + raise BadCredentialsError('Account is disabled') + + except BadCredentialsError, e: + msg = str(e) + + _logger.debug('Unsuccessful authentication attempt: %s' % msg) + + if request.json: + return _json_auth_error(msg) + + do_flash(msg, 'error') + + return redirect(request.referrer or _security.login_manager.login_view) + + +def logout(): + """View function which handles a logout request.""" + + for key in ('identity.name', 'identity.auth_type'): + session.pop(key, None) + + identity_changed.send(app._get_current_object(), + identity=AnonymousIdentity()) + + logout_user() + _logger.debug('User logged out') + + return redirect(request.args.get('next', None) or + _security.post_logout_view) + + +def register_user(): + """View function which handles a registration request.""" + + form = RegisterForm(csrf_enabled=not app.testing) + + if form.validate_on_submit(): + # Create user + u = _datastore.create_user(**form.to_dict()) + + # Send confirmation instructions if necessary + t = reset_confirmation_token(u) if _security.confirmable else None + + data = dict(user=u, confirm_token=t) + user_registered.send(data, app=app._get_current_object()) + + _logger.debug('User %s registered' % u) + + # Login the user if allowed + if not _security.confirmable or _security.login_without_confirmation: + _do_login(u) + + return redirect(_security.post_register_view or + _security.post_login_view) + + return render_template('security/registrations/new.html', + register_user_form=form) + + +def send_confirmation(): + form = ResendConfirmationForm() + + if form.validate_on_submit(): + user = _datastore.find_user(email=form.email.data) + + reset_confirmation_token(user) + + _logger.debug('%s request confirmation instructions' % user) + + msg, cat = get_message('CONFIRMATION_REQUEST', email=user.email) + + do_flash(msg, cat) + + else: + for key, value in form.errors.items(): + do_flash(value[0], 'error') + + return render_template('security/confirmations/new.html', + reset_confirmation_form=form) + + +def confirm_account(token): + """View function which handles a account confirmation request.""" + try: + user = confirm_by_token(token) + _logger.debug('%s confirmed their account' % user) + + except ConfirmationError, e: + msg, cat = str(e), 'error' + + _logger.debug('Confirmation error: ' + msg) + + if e.user: + reset_confirmation_token(e.user) + + msg, cat = get_message('CONFIRMATION_EXPIRED', + within=_security.confirm_email_within, + email=e.user.email) + + do_flash(msg, cat) + + return redirect(get_url(_security.confirm_error_view)) + + do_flash(get_message('ACCOUNT_CONFIRMED')) + + return redirect(_security.post_confirm_view or _security.post_login_view) + + +def forgot_password(): + """View function that handles a forgotten password request.""" + + form = ForgotPasswordForm(csrf_enabled=not app.testing) + + if form.validate_on_submit(): + user = _datastore.find_user(**form.to_dict()) + + reset_password_reset_token(user) + + _logger.debug('%s requested to reset their password' % user) + + msg, cat = get_message('PASSWORD_RESET_REQUEST', email=user.email) + + do_flash(msg, cat) + + return redirect(_security.post_forgot_view) + + else: + _logger.debug('A reset password request was made for %s but ' + 'that email does not exist.' % form.email.data) + + for key, value in form.errors.items(): + do_flash(value[0], 'error') + + return render_template('security/passwords/new.html', + forgot_password_form=form) + + +def reset_password(token): + """View function that handles a reset password request.""" + + form = ResetPasswordForm(csrf_enabled=not app.testing) + + if form.validate_on_submit(): + try: + user = reset_by_token(token=token, **form.to_dict()) + _logger.debug('%s reset their password' % user) + + except ResetPasswordError, e: + msg, cat = str(e), 'error' + + _logger.debug('Password reset error: ' + msg) + + if e.user: + reset_password_reset_token(e.user) + + msg, cat = get_message('PASSWORD_RESET_EXPIRED', + within=_security.reset_password_within, + email=e.user.email) + + do_flash(msg, cat) + + return render_template('security/passwords/edit.html', + reset_password_form=form, + password_reset_token=token) + + +def create_blueprint(app, name, import_name, **kwargs): + bp = Blueprint(name, import_name, **kwargs) + + bp.route(config_value('AUTH_URL', app=app), + methods=['POST'], + endpoint='authenticate')(authenticate) + + bp.route(config_value('LOGOUT_URL', app=app), + endpoint='logout')(login_required(logout)) + + if config_value('REGISTERABLE', app=app): + bp.route(config_value('REGISTER_URL', app=app), + methods=['GET', 'POST'], + endpoint='register')(register_user) + + if config_value('RECOVERABLE', app=app): + bp.route(config_value('RESET_URL', app=app), + methods=['GET', 'POST'], + endpoint='forgot_password')(forgot_password) + bp.route(config_value('RESET_URL', app=app) + '/', + methods=['GET', 'POST'], + endpoint='reset_password')(reset_password) + + if config_value('CONFIRMABLE', app=app): + bp.route(config_value('CONFIRM_URL', app=app), + methods=['GET', 'POST'], + endpoint='send_confirmation')(send_confirmation) + bp.route(config_value('CONFIRM_URL', app=app) + '/', + methods=['GET', 'POST'], + endpoint='confirm_account')(confirm_account) + + return bp diff --git a/flask_security/__init__.py b/flask_security/__init__.py index 3464c24c..cca9f3e5 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -14,6 +14,7 @@ from .core import Security, RoleMixin, UserMixin, AnonymousUser, \ AuthenticationProvider, current_user +from .datastore import SQLAlchemyUserDatastore, MongoEngineUserDatastore from .decorators import auth_token_required, http_auth_required, \ login_required, roles_accepted, roles_required from .forms import ForgotPasswordForm, LoginForm, RegisterForm, \ diff --git a/flask_security/views.py b/flask_security/views.py index 4ea617c0..e9eb2ed3 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -18,17 +18,17 @@ from werkzeug.datastructures import MultiDict from werkzeug.local import LocalProxy -from .confirmable import confirm_by_token, reset_confirmation_token -from .decorators import login_required -from .exceptions import ConfirmationError, BadCredentialsError, \ +from flask_security.confirmable import confirm_by_token, reset_confirmation_token +from flask_security.decorators import login_required +from flask_security.exceptions import ConfirmationError, BadCredentialsError, \ ResetPasswordError -from .forms import LoginForm, RegisterForm, ForgotPasswordForm, \ +from flask_security.forms import LoginForm, RegisterForm, ForgotPasswordForm, \ ResetPasswordForm, ResendConfirmationForm -from .recoverable import reset_by_token, \ +from flask_security.recoverable import reset_by_token, \ reset_password_reset_token -from .signals import user_registered -from .tokens import generate_authentication_token -from .utils import get_url, get_post_login_redirect, do_flash, \ +from flask_security.signals import user_registered +from flask_security.tokens import generate_authentication_token +from flask_security.utils import get_url, get_post_login_redirect, do_flash, \ get_remember_token, get_message, config_value From e570997ea0a00aa486239afd40f09e7b0d6a3f0a Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 13 Jul 2012 12:26:58 -0400 Subject: [PATCH 093/234] Remove flag, its meant for testing --- example/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/app.py b/example/app.py index 2e20bf4c..38a8f0d5 100644 --- a/example/app.py +++ b/example/app.py @@ -203,6 +203,6 @@ def before_first_request(): return app if __name__ == '__main__': - app = create_sqlalchemy_app(register_blueprint=False) + app = create_sqlalchemy_app() #app = create_mongoengine_app() app.run() From 7212e20a76925115ea5e929a35dd0a19e0757393 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 13 Jul 2012 13:42:34 -0400 Subject: [PATCH 094/234] no pypy --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 15579352..de5567fe 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,6 @@ python: - "2.5" - "2.6" - "2.7" - - "pypy" install: - pip install . --quiet --use-mirrors From 0a0b5ecade7e1e79a9d4d27ae18a58570a9572d9 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 13 Jul 2012 13:50:36 -0400 Subject: [PATCH 095/234] Get rid of `login_required` decorator from `roles_required` and `roles_accepted` in order to be able to pair `http_auth_required` with `roles_required` or `roles_accepted`. Just be sure to put `http_auth_required` first. --- flask_security/decorators.py | 12 ++++++++---- tests/functional_tests.py | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/flask_security/decorators.py b/flask_security/decorators.py index cfdb4922..cba86585 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -13,7 +13,7 @@ from flask import current_app, Response, request, redirect from flask.ext.login import login_required, login_url, current_user -from flask.ext.principal import RoleNeed, Permission +from flask.ext.principal import RoleNeed, Permission, Identity, identity_changed from werkzeug.local import LocalProxy from . import utils @@ -77,7 +77,13 @@ def _check_http_auth(): except UserNotFoundError: return False - return _security.pwd_context.verify(auth.password, user.password) + rv = _security.pwd_context.verify(auth.password, user.password) + + if rv: + identity_changed.send(current_app._get_current_object(), + identity=Identity(user.id)) + + return rv def http_auth_required(realm): @@ -132,7 +138,6 @@ def dashboard(): def wrapper(fn): @wraps(fn) - @login_required def decorated_view(*args, **kwargs): perms = [Permission(RoleNeed(role)) for role in roles] @@ -167,7 +172,6 @@ def create_post(): def wrapper(fn): @wraps(fn) - @login_required def decorated_view(*args, **kwargs): perm = Permission(*[RoleNeed(role) for role in roles]) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 01dfa959..7fa4fb18 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -88,7 +88,7 @@ def test_roles_accepted(self): def test_unauthenticated_role_required(self): r = self._get('/admin', follow_redirects=True) - self.assertIn(' Date: Fri, 13 Jul 2012 16:05:25 -0400 Subject: [PATCH 096/234] Remove unnecessary string formatting --- flask_security/script.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/flask_security/script.py b/flask_security/script.py index 8a5e425c..85b61f86 100644 --- a/flask_security/script.py +++ b/flask_security/script.py @@ -138,10 +138,7 @@ def run(self, output): TODO: Documentation URL \""" """ - ctx = dict(module_name=inspect.getmodulename(output)) - to_replace = to_replace % ctx contents = s.read().replace(to_remove, to_replace) - o.write(contents) print 'File generated successfully.' From 42bffc4234497a3063470fee8be497ba5d74a21d Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 13 Jul 2012 16:05:40 -0400 Subject: [PATCH 097/234] Fix templates --- flask_security/templates/security/logins/new.html | 14 +++++++------- .../templates/security/registrations/edit.html | 2 +- .../templates/security/registrations/new.html | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/flask_security/templates/security/logins/new.html b/flask_security/templates/security/logins/new.html index b3871f74..f5d9998b 100644 --- a/flask_security/templates/security/logins/new.html +++ b/flask_security/templates/security/logins/new.html @@ -1,10 +1,10 @@ -{% include "../messages.html" %} +{% include "security/messages.html" %}

    Login

    - {{ form.hidden_tag() }} - {{ form.email.label }} {{ form.email }}
    - {{ form.password.label }} {{ form.password }}
    - {{ form.remember.label }} {{ form.remember }}
    - {{ form.next }} - {{ form.submit }} + {{ login_form.hidden_tag() }} + {{ login_form.email.label }} {{ login_form.email }}
    + {{ login_form.password.label }} {{ login_form.password }}
    + {{ login_form.remember.label }} {{ login_form.remember }}
    + {{ login_form.next }} + {{ login_form.submit }} \ No newline at end of file diff --git a/flask_security/templates/security/registrations/edit.html b/flask_security/templates/security/registrations/edit.html index 5c2f24bf..de100f76 100644 --- a/flask_security/templates/security/registrations/edit.html +++ b/flask_security/templates/security/registrations/edit.html @@ -1,4 +1,4 @@ -{% include "../messages.html" %} +{% include "security/messages.html" %}

    Edit account

    {{ edit_user_form.hidden_tag() }} diff --git a/flask_security/templates/security/registrations/new.html b/flask_security/templates/security/registrations/new.html index dc0fdd83..133f5d89 100644 --- a/flask_security/templates/security/registrations/new.html +++ b/flask_security/templates/security/registrations/new.html @@ -1,4 +1,4 @@ -{% include "../messages.html" %} +{% include "security/messages.html" %}

    Register

    {{ register_user_form.hidden_tag() }} From fcab270f25bdb77daefd792fc1d9398639472206 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 13 Jul 2012 16:06:02 -0400 Subject: [PATCH 098/234] Include templates --- MANIFEST.in | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 3f4ce4fa..5538354c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ -include tests/*.py \ No newline at end of file +recursive-include tests *.py +recursive-include flask_security/templates *.* \ No newline at end of file From 507de82aba94e1bb1adc5aa17aa389b4cfcae60a Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 13 Jul 2012 16:06:10 -0400 Subject: [PATCH 099/234] Update docs --- docs/index.rst | 361 +++++++++++++++++------------------ flask_security/decorators.py | 12 +- 2 files changed, 183 insertions(+), 190 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index fd587fab..7987894d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,32 +3,20 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Flask-Security -============== +Extending Flask-Security +======================== .. module:: flask_security -Simple security for Flask applications combining -`Flask-Login `_, -`Flask-Principal `_, -`Flask-WTF `_, -`passlib `_, and your choice of datastore. -Currently `SQLAlchemy `_ via -`Flask-SQLAlchemy `_ and -`MongoEngine `_ via -`Flask-MongoEngine `_ are supported -out of the box. You will need to install the necessary Flask extensions that -you'll be using on your own. Additionally, you may need to install an encryption -library such as `py-bcrypt `_ (if -you plan to use bcrypt) for your desired encryption method. +Quick and simple security for Flask applications. Contents ========= * :ref:`overview` * :ref:`installation` -* :ref:`getting-started` -* :ref:`additional-user-fields` +* :ref:`quick-start` +* :ref:`models` * :ref:`flask-script-commands` * :ref:`api` * :doc:`Changelog ` @@ -39,17 +27,34 @@ Contents Overview ======== -Flask-Security does a few things that Flask-Login and Flask-Principal don't -provide out of the box. They are: +Flask-Security allows you to quickly add common user and security mechanisms to +your Flask application. They include: -1. Setting up login and logout endpoints -2. Authenticating users based on username or email -3. Limiting access based on user 'roles' -4. User and role creation -5. Password encryption +1. Session based authentication +2. Role management +3. Password encryption +4. Basic HTTP authentication +5. Token based authentication +6. Token based account activation (optional) +7. Token based password recovery/resetting (optional) +8. User registration (optional) +9. Login tracking (optional) +10. Basic user management commands -That being said, you can still hook into things such as the Flask-Login and -Flask-Principal signals if need be. +Many of these features are made possible by integrating various Flask extensions +and libraries. They include: + +1. Flask-Login +2. Flask-Mail +3. Flask-Principal +4. Flask-Script +5. Flask-WTF +6. itsdangerous +7. passlib + +Additionally, it assumes you'll be using a common library for your database +connections and model definitions. Flask-Security thus supports SQLAlchemy and +MongoEngine out of the box and additional libraries can easily be supported. .. _installation: @@ -66,109 +71,93 @@ Then install your datastore requirement. **SQLAlchemy**:: - $ pip install Flask-SQLAlchemy + $ pip install flask-sqlalchemy **MongoEngine**:: - $ pip install https://github.com/sbook/flask-mongoengine/tarball/master + $ pip install flask-mongoengine + +And lastly install any password encryption library that you may need. For +example:: + + $ pip install py-bcrypt -.. _getting-started: +.. _quick-start: -Getting Started -=============== +Quick Start Example +=================== -The following code samples will illustrate how to get started using SQLAlchemy. -First thing you'll want to do is setup your application and datastore:: +The following code sample illustrates how to get started as quickly as possible +using SQLAlchemy.:: - from flask import Flask, render_template + from flask import Flask, render_template, url_for from flask.ext.sqlalchemy import SQLAlchemy - from flask.ext.security import (User, Security, LoginForm, login_required, - roles_accepted, user_datastore) - from flask.ext.security.datastore.sqlalchemy import SQLAlchemyUserDatastore - + from flask.ext.security import Security, UserMixin, RoleMixin, \ + login_required + from flask.ext.security.datastore import SQLAlchemyUserDatastore + from flask.ext.security.forms import LoginForm + app = Flask(__name__) + app.debug = True app.config['SECRET_KEY'] = 'secret' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' - + app.config['SECURITY_POST_LOGIN_VIEW'] = '/protected' + db = SQLAlchemy(app) - Security(app, SQLAlchemyUserDatastore(db)) -You'll probably want to at least one user to the database to test this out. -There are many ways to do this, but this is a quick and dirty way to do it:: + roles_users = db.Table('roles_users', + db.Column('user_id', db.Integer(), db.ForeignKey('user.id')), + db.Column('role_id', db.Integer(), db.ForeignKey('role.id'))) - @app.before_first_request - def before_first_request(): - user_datastore.create_role(name='admin') - user_datastore.create_user(username='matt', email='matt@something.com', - password='password', roles=['admin']) - -Next you'll want to setup your login screen. Setup your view:: + class Role(db.Model, RoleMixin): + id = db.Column(db.Integer(), primary_key=True) + name = db.Column(db.String(80), unique=True) + description = db.Column(db.String(255)) - @app.route("/login") - def login(): - return render_template('login.html', form=LoginForm()) - -And corresponding template:: - - - {{ form.hidden_tag() }} - {{ form.username.label }} {{ form.username }}
    - {{ form.password.label }} {{ form.password }}
    - {{ form.remember.label }} {{ form.remember }}
    - {{ form.submit }} - - -By default, Flask-Security will redirect a user to `/profile` after logging in. -You can set this page up yourself or set the `SECURITY_POST_LOGIN` config -value to change this behavior. Regardless, setup a protected view as such:: + class User(db.Model, UserMixin): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True) + password = db.Column(db.String(120)) + remember_token = db.Column(db.String(255)) + active = db.Column(db.Boolean()) + authentication_token = db.Column(db.String(255)) + roles = db.relationship('Role', secondary=roles_users, + backref=db.backref('users', lazy='dynamic')) - @app.route('/profile') - @login_required - def profile(): - return render_template('profile.html') - -Now you have an application with basic authentication. If you run the local -development server you can visit `http://localhost:5000/login `_ -to login. + datastore = SQLAlchemyUserDatastore(db, User, Role) -The last thing you'll want to do is add a logout link to your templates. This -can be achieved with:: + Security(app, datastore) - Logout - -Now, for instance, say you want to protect an admin area to users that are -administrators. You can use the `roles_accepted` decorator to prevent access. -The corresponding view would look like such:: - - @app.route('/admin') - @roles_accepted('admin') - def admin(): - return render_template('admin/index.html') - -And lastly, maybe you only want to show something in a template if a user has a -specific role:: + @app.before_first_request + def add_user(): + db.create_all() + datastore.create_user(email='matt@matt.com', + password='password') - {% if current_user.has_role('admin') %} - Admin Panel - {$ endif %} + @app.route('/') + @app.route('/login') + def login(): + return render_template('security/logins/new.html', + login_form=LoginForm()) + @app.route('/protected') + @login_required + def protected(): + return """

    You are logged in

    +

    Log out""" % ( + url_for('flask_security.logout')) -.. _additional-user-fields: + if __name__ == '__main__': + app.run() -Additional User Fields ----------------------- -If you'd like to add additional fields to the user model you can use a mixin -class that specifies your additional fields. The following is an example of -how you might do this:: - db = SQLAlchemy(app) +.. _models: - class UserAccountMixin(): - first_name = db.Column(db.String(120)) - last_name = db.Column(db.String(120)) +Data Models +----------- - Security(app, SQLAlchemyUserDatastore(db, UserAccountMixin)) +TODO: Describe models and required fields for various features .. _flask-script-commands: @@ -176,12 +165,14 @@ Flask-Script Commands --------------------- Flask-Security comes packed with a few Flask-Script commands. They are: -* :class:`flask.ext.security.script.CreateUserCommand` -* :class:`flask.ext.security.script.CreateRoleCommand` -* :class:`flask.ext.security.script.AddRoleCommand` -* :class:`flask.ext.security.script.RemoveRoleCommand` -* :class:`flask.ext.security.script.DeactivateUserCommand` -* :class:`flask.ext.security.script.ActivateUserCommand` +* :class:`flask_security.script.CreateUserCommand` +* :class:`flask_security.script.CreateRoleCommand` +* :class:`flask_security.script.AddRoleCommand` +* :class:`flask_security.script.RemoveRoleCommand` +* :class:`flask_security.script.DeactivateUserCommand` +* :class:`flask_security.script.ActivateUserCommand` +* :class:`flask_security.script.ActivateUserCommand` +* :class:`flask_security.script.GenerateBlueprintCommand` Register these on your script manager for pure convenience. @@ -192,25 +183,63 @@ Configuration Values ==================== * :attr:`SECURITY_URL_PREFIX`: Specifies the URL prefix for the Security - blueprint -* :attr:`SECURITY_AUTH_PROVIDER`: Specifies the class to use as the - authentication provider. Such as `flask.ext.security.AuthenticationProvider` + blueprint. +* :attr:`SECURITY_FLASH_MESSAGES`: Specifies wether or not to flash messages + during security mechanisms. * :attr:`SECURITY_PASSWORD_HASH`: Specifies the encryption method to use. e.g.: - plaintext, bcrypt, etc -* :attr:`SECURITY_USER_DATASTORE`: Specifies the property name to use for the - user datastore on the application instance -* :attr:`SECURITY_LOGIN_FORM`: Specifies the form class to use when processing - an authentication request -* :attr:`SECURITY_AUTH_URL`: Specifies the URL to to handle authentication -* :attr:`SECURITY_LOGOUT_URL`: Specifies the URL to process a logout request + plaintext, bcrypt, etc. +* :attr:`SECURITY_AUTH_URL`: Specifies the URL to to handle authentication. +* :attr:`SECURITY_LOGOUT_URL`: Specifies the URL to process a logout request. +* :attr:`SECURITY_REGISTER_URL`: Specifies the URL for user registrations. +* :attr:`SECURITY_RESET_URL`: Specifies the URL for password resets. +* :attr:`SECURITY_CONFIRM_URL`: Specifies the URL for account confirmations. * :attr:`SECURITY_LOGIN_VIEW`: Specifies the URL to redirect to when - authentication is required -* :attr:`SECURITY_POST_LOGIN`: Specifies the URL to redirect to after a user is - authenticated -* :attr:`SECURITY_POST_LOGOUT`: Specifies the URL to redirect to after a user - logs out -* :attr:`SECURITY_FLASH_MESSAGES`: Specifies wether or not to flash messages - during authentication request + authentication is required. +* :attr:`SECURITY_CONFIRM_ERROR_VIEW`: Specifies the URL to redirect to when + an confirmation error occurs. +* :attr:`SECURITY_POST_LOGIN_VIEW`: Specifies the URL to redirect to after a + user logins in. +* :attr:`SECURITY_POST_LOGOUT_VIEW`: Specifies the URL to redirect to after a + user logs out. +* :attr:`SECURITY_POST_FORGOT_VIEW`: Specifies the URL to redirect to after a + user requests password reset instructions. +* :attr:`SECURITY_RESET_PASSWORD_ERROR_VIEW`: Specifies the URL to redirect to + after an error occurs during the password reset process. +* :attr:`SECURITY_POST_REGISTER_VIEW`: Specifies the URL to redirect to after a + user successfully registers. +* :attr:`SECURITY_POST_CONFIRM_VIEW`: Specifies the URL to redirect to after a + user successfully confirms their account. +* :attr:`SECURITY_UNAUTHORIZED_VIEW`: Specifies the URL to redirect to when a + user attempts to access a view they don't have permission to view. +* :attr:`SECURITY_DEFAULT_ROLES`: The default roles any new users should have. +* :attr:`SECURITY_CONFIRMABLE`: Enables confirmation features. Defaults to + `False`. +* :attr:`SECURITY_REGISTERABLE`: Enables user registration features. Defaults to + `False`. +* :attr:`SECURITY_RECOVERABLE`: Enables password reset/recovery features. + Defaults to `False`. +* :attr:`SECURITY_TRACKABLE`: Enables login tracking features. Defaults to + `False`. +* :attr:`SECURITY_CONFIRM_EMAIL_WITHIN`: Specifies the amount of time a user + has to confirm their account/email. Default is `5 days`. +* :attr:`SECURITY_RESET_PASSWORD_WITHIN`: Specifies the amount of time a user + has to reset their password. Default is `5 days`. +* :attr:`SECURITY_LOGIN_WITHOUT_CONFIRMATION`: Specifies if users can login + without first confirming their accounts. Defaults to `False` +* :attr:`SECURITY_EMAIL_SENDER`: Specifies the email address to send emails on + behalf of. Defaults to `no-reply@localhost`. +* :attr:`SECURITY_TOKEN_AUTHENTICATION_KEY`: Specifies the query string argument + to use during token authentication. Defaults to `auth_token`. +* :attr:`SECURITY_TOKEN_AUTHENTICATION_HEADER`: Specifies the header name to use + during token authentication. Defaults to `X-Auth-Token`. +* :attr:`SECURITY_CONFIRM_SALT`: Specifies the salt value to use for account + confirmation tokens. Defaults to `confirm-salt`. +* :attr:`SECURITY_RESET_SALT`: Specifies the salt value to use for password + reset tokens. Defaults to `reset-salt`. +* :attr:`SECURITY_AUTH_SALT`: Specifies the salt value to use for token based + authentication tokens. Defaults to `auth-salt`. +* :attr:`SECURITY_DEFAULT_HTTP_AUTH_REALM`: Specifies the default basic HTTP + authentication realm. Defaults to `Login Required`. .. _api: @@ -218,32 +247,36 @@ Configuration Values API === -.. autoclass:: flask_security.Security +.. autoclass:: flask_security.core.Security :members: -.. data:: flask_security.current_user +.. data:: flask_security.core.current_user A proxy for the current user. Protecting Views ---------------- -.. autofunction:: flask_security.login_required +.. autofunction:: flask_security.decorators.login_required -.. autofunction:: flask_security.roles_required +.. autofunction:: flask_security.decorators.roles_required + +.. autofunction:: flask_security.decorators.roles_accepted -.. autofunction:: flask_security.roles_accepted +.. autofunction:: flask_security.decorators.http_auth_required + +.. autofunction:: flask_security.decorators.auth_token_required User Object Helpers ------------------- -.. autoclass:: flask_security.UserMixin +.. autoclass:: flask_security.core.UserMixin :members: -.. autoclass:: flask_security.RoleMixin +.. autoclass:: flask_security.core.RoleMixin :members: -.. autoclass:: flask_security.AnonymousUser +.. autoclass:: flask_security.core.AnonymousUser :members: @@ -252,65 +285,13 @@ Datastores .. autoclass:: flask_security.datastore.UserDatastore :members: -.. autoclass:: flask_security.datastore.sqlalchemy.SQLAlchemyUserDatastore +.. autoclass:: flask_security.datastore.SQLAlchemyUserDatastore :members: :inherited-members: -.. autoclass:: flask_security.datastore.mongoengine.MongoEngineUserDatastore +.. autoclass:: flask_security.datastore.MongoEngineUserDatastore :members: :inherited-members: - - -Models ------- -.. autoclass:: flask_security.User - - .. attribute:: id - - User ID - - .. attribute:: username - - Username - - .. attribute:: email - - Email address - - .. attribute:: password - - Password - - .. attribute:: active - - Active state - - .. attribute:: roles - - User roles - - .. attribute:: created_at - - Created date - - .. attribute:: modified_at - - Modified date - - -.. autoclass:: flask_security.Role - - .. attribute:: id - - Role ID - - .. attribute:: name - - Role name - - .. attribute:: description - - Role description Exceptions @@ -331,6 +312,10 @@ Exceptions .. autoexception:: flask_security.RoleCreationError +.. autoexception:: flask_security.ConfirmationError + +.. autoexception:: flask_security.ResetPasswordError + Signals ------- diff --git a/flask_security/decorators.py b/flask_security/decorators.py index cba86585..5406e4fe 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -87,7 +87,10 @@ def _check_http_auth(): def http_auth_required(realm): - """Decorator that protects endpoints using Basic HTTP authentication.""" + """Decorator that protects endpoints using Basic HTTP authentication. + The username should be set to the user's email address. + + :param realm: optional realm name""" def decorator(fn): @wraps(fn) @@ -109,7 +112,12 @@ def wrapper(*args, **kwargs): def auth_token_required(fn): - """Decorator that protects endpoints using token authentication.""" + """Decorator that protects endpoints using token authentication. The token + should be added to the request by the client by using a query string + variable with a name equal to the configuration value of + `SECURITY_TOKEN_AUTHENTICATION_KEY` or in a request header named that of + the configuration value of `SECURITY_TOKEN_AUTHENTICATION_HEADER` + """ @wraps(fn) def decorated(*args, **kwargs): From 54da59f0032c070cbd7647be70d4d180b0f59b4e Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 13 Jul 2012 16:11:25 -0400 Subject: [PATCH 100/234] Clean up --- docs/index.rst | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 7987894d..20ab865d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,10 +1,6 @@ -.. Flask-Security documentation master file, created by - sphinx-quickstart on Mon Mar 12 15:35:21 2012. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. -Extending Flask-Security -======================== +Flask-Security +============== .. module:: flask_security @@ -296,25 +292,25 @@ Datastores Exceptions ---------- -.. autoexception:: flask_security.BadCredentialsError +.. autoexception:: flask_security.exceptions.BadCredentialsError -.. autoexception:: flask_security.AuthenticationError +.. autoexception:: flask_security.exceptions.AuthenticationError -.. autoexception:: flask_security.UserNotFoundError +.. autoexception:: flask_security.exceptions.UserNotFoundError -.. autoexception:: flask_security.RoleNotFoundError +.. autoexception:: flask_security.exceptions.RoleNotFoundError -.. autoexception:: flask_security.UserIdNotFoundError +.. autoexception:: flask_security.exceptions.UserIdNotFoundError -.. autoexception:: flask_security.UserDatastoreError +.. autoexception:: flask_security.exceptions.UserDatastoreError -.. autoexception:: flask_security.UserCreationError +.. autoexception:: flask_security.exceptions.UserCreationError -.. autoexception:: flask_security.RoleCreationError +.. autoexception:: flask_security.exceptions.RoleCreationError -.. autoexception:: flask_security.ConfirmationError +.. autoexception:: flask_security.exceptions.ConfirmationError -.. autoexception:: flask_security.ResetPasswordError +.. autoexception:: flask_security.exceptions.ResetPasswordError Signals From f170cb434c1dbeb690affbd7d301d7ed94b4b797 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 16 Jul 2012 19:07:19 -0400 Subject: [PATCH 101/234] Use a stateful object instead of arbitrary assignment of extension on app object --- example/app.py | 6 ++-- flask_security/confirmable.py | 4 +-- flask_security/core.py | 58 +++++++++++++++++++++++------------ flask_security/datastore.py | 9 ++++-- flask_security/decorators.py | 2 +- flask_security/forms.py | 2 +- flask_security/recoverable.py | 4 +-- flask_security/script.py | 2 +- flask_security/tokens.py | 4 +-- flask_security/utils.py | 7 ++++- flask_security/views.py | 4 +-- tests/functional_tests.py | 2 +- 12 files changed, 66 insertions(+), 38 deletions(-) diff --git a/example/app.py b/example/app.py index 38a8f0d5..69e0b6ed 100644 --- a/example/app.py +++ b/example/app.py @@ -149,8 +149,8 @@ class User(db.Model, UserMixin): roles = db.relationship('Role', secondary=roles_users, backref=db.backref('users', lazy='dynamic')) - Security(app, SQLAlchemyUserDatastore(db, User, Role), - register_blueprint=register_blueprint) + app.security = Security(app, SQLAlchemyUserDatastore(db, User, Role), + register_blueprint=register_blueprint) if not register_blueprint: from example import security @@ -192,7 +192,7 @@ class User(db.Document, UserMixin): authentication_token = db.StringField(max_length=255) roles = db.ListField(db.ReferenceField(Role), default=[]) - Security(app, MongoEngineUserDatastore(db, User, Role)) + app.security = Security(app, MongoEngineUserDatastore(db, User, Role)) @app.before_first_request def before_first_request(): diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 58ceff7c..9c703a8c 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -21,9 +21,9 @@ # Convenient references -_security = LocalProxy(lambda: app.security) +_security = LocalProxy(lambda: app.extensions['security']) -_datastore = LocalProxy(lambda: app.security.datastore) +_datastore = LocalProxy(lambda: _security.datastore) def send_confirmation_instructions(user, token): diff --git a/flask_security/core.py b/flask_security/core.py index 8050cc1e..f4d82199 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -17,11 +17,15 @@ identity_loaded from passlib.context import CryptContext from werkzeug.datastructures import ImmutableList +from werkzeug.local import LocalProxy from . import views, exceptions from .confirmable import requires_confirmation from .utils import config_value as cv, get_config +# Convenient references +_security = LocalProxy(lambda: current_app.extensions['security']) + #: Default Flask-Security configuration _default_config = { @@ -75,7 +79,7 @@ def _user_loader(user_id): try: - return current_app.security.datastore.with_id(user_id) + return _security.datastore.with_id(user_id) except Exception, e: current_app.logger.error('Error getting user: %s' % e) return None @@ -83,7 +87,7 @@ def _user_loader(user_id): def _token_loader(token): try: - return current_app.security.datastore.find_user(remember_token=token) + return _security.datastore.find_user(remember_token=token) except Exception, e: current_app.logger.error('Error getting user: %s' % e) return None @@ -189,6 +193,13 @@ def has_role(self, *args): return False +class _SecurityState(object): + + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key.lower(), value) + + class Security(object): """The :class:`Security` class initializes the Flask-Security extension. @@ -196,7 +207,11 @@ class Security(object): :param datastore: An instance of a user datastore. """ def __init__(self, app=None, datastore=None, **kwargs): - self.init_app(app, datastore, **kwargs) + self.app = app + self.datastore = datastore + + if app is not None and datastore is not None: + self.init_app(app, datastore, **kwargs) def init_app(self, app, datastore, register_blueprint=True): """Initializes the Flask-Security extension for the specified @@ -205,8 +220,6 @@ def init_app(self, app, datastore, register_blueprint=True): :param app: The application. :param datastore: An instance of a user datastore. """ - if app is None or datastore is None: - return for key, value in _default_config.items(): app.config.setdefault('SECURITY_' + key, value) @@ -214,18 +227,6 @@ def init_app(self, app, datastore, register_blueprint=True): for key, value in _default_flash_messages.items(): app.config.setdefault('SECURITY_MSG_' + key, value) - self.datastore = datastore - self.auth_provider = AuthenticationProvider() - self.login_manager = _get_login_manager(app) - self.principal = _get_principal(app) - self.pwd_context = _get_pwd_context(app) - self.reset_serializer = _get_reset_serializer(app) - self.confirm_serializer = _get_confirm_serializer(app) - self.token_auth_serializer = _get_token_auth_serializer(app) - - for key, value in get_config(app).items(): - setattr(self, key.lower(), value) - identity_loaded.connect_via(app)(_on_identity_loaded) if register_blueprint: @@ -234,13 +235,30 @@ def init_app(self, app, datastore, register_blueprint=True): url_prefix=cv('URL_PREFIX', app=app)) app.register_blueprint(bp) - app.security = self + if not hasattr(app, 'extensions'): + app.extensions = {} + + kwargs = {} + for key, value in get_config(app).items(): + kwargs[key.lower()] = value + + app.extensions['security'] = _SecurityState( + app=app, + datastore=datastore, + auth_provider=AuthenticationProvider(), + login_manager=_get_login_manager(app), + principal=_get_principal(app), + pwd_context=_get_pwd_context(app), + reset_serializer=_get_reset_serializer(app), + confirm_serializer=_get_confirm_serializer(app), + token_auth_serializer=_get_token_auth_serializer(app), + **kwargs) class AuthenticationProvider(object): """The default authentication provider implementation.""" def _get_user(self, username_or_email): - datastore = current_app.security.datastore + datastore = _security.datastore try: return datastore.find_user(email=username_or_email) @@ -286,7 +304,7 @@ def do_authenticate(self, username_or_email, password): raise exceptions.BadCredentialsError('Account requires confirmation') # compare passwords - if current_app.security.pwd_context.verify(password, user.password): + if _security.pwd_context.verify(password, user.password): return user # bad match diff --git a/flask_security/datastore.py b/flask_security/datastore.py index bd076f5f..98d52139 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -10,9 +10,13 @@ """ from flask import current_app +from werkzeug.local import LocalProxy from . import exceptions, utils +# Convenient references +_security = LocalProxy(lambda: current_app.extensions['security']) + class UserDatastore(object): """Abstracted user datastore. Always extend this class and implement the @@ -88,7 +92,7 @@ def _prepare_create_user_args(self, kwargs): password = kwargs.get('password', None) kwargs.setdefault('active', True) - kwargs.setdefault('roles', current_app.security.default_roles) + kwargs.setdefault('roles', _security.default_roles) if email is None: raise exceptions.UserCreationError('Missing email argument') @@ -105,8 +109,9 @@ def _prepare_create_user_args(self, kwargs): kwargs['roles'] = roles - pwd_context = current_app.security.pwd_context + pwd_context = _security.pwd_context pw = kwargs['password'] + if not pwd_context.identify(pw): kwargs['password'] = pwd_context.encrypt(pw) diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 5406e4fe..93bfe8a7 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -21,7 +21,7 @@ # Convenient references -_security = LocalProxy(lambda: current_app.security) +_security = LocalProxy(lambda: current_app.extensions['security']) _logger = LocalProxy(lambda: current_app.logger) diff --git a/flask_security/forms.py b/flask_security/forms.py index 4be9bbe4..9fab6f45 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -18,7 +18,7 @@ # Convenient reference -_datastore = LocalProxy(lambda: app.security.datastore) +_datastore = LocalProxy(lambda: app.extensions['security'].datastore) def valid_user_email(form, field): diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index cebd57e9..48fd1b55 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -20,9 +20,9 @@ # Convenient references -_security = LocalProxy(lambda: app.security) +_security = LocalProxy(lambda: app.extensions['security']) -_datastore = LocalProxy(lambda: app.security.datastore) +_datastore = LocalProxy(lambda: _security.datastore) def send_reset_password_instructions(user, reset_token): diff --git a/flask_security/script.py b/flask_security/script.py index 85b61f86..ca01c930 100644 --- a/flask_security/script.py +++ b/flask_security/script.py @@ -15,7 +15,7 @@ from flask_security import views -_datastore = LocalProxy(lambda: current_app.security.datastore) +_datastore = LocalProxy(lambda: current_app.extensions['security'].datastore) def pprint(obj): diff --git a/flask_security/tokens.py b/flask_security/tokens.py index f6665f0a..f896469e 100644 --- a/flask_security/tokens.py +++ b/flask_security/tokens.py @@ -16,9 +16,9 @@ # Convenient references -_security = LocalProxy(lambda: app.security) +_security = LocalProxy(lambda: app.extensions['security']) -_datastore = LocalProxy(lambda: app.security.datastore) +_datastore = LocalProxy(lambda: _security.datastore) def generate_authentication_token(user): diff --git a/flask_security/utils.py b/flask_security/utils.py index 62274baf..fdbcf398 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -17,10 +17,15 @@ from flask import url_for, flash, current_app, request, session, render_template from flask.ext.login import make_secure_token +from werkzeug.local import LocalProxy from .signals import user_registered, password_reset_requested +# Convenient references +_security = LocalProxy(lambda: current_app.extensions['security']) + + def md5(data): return hashlib.md5(data).hexdigest() @@ -150,7 +155,7 @@ def send_mail(subject, recipient, template, context=None): context = context or {} msg = Message(subject, - sender=current_app.security.email_sender, + sender=_security.email_sender, recipients=[recipient]) base = 'security/email' diff --git a/flask_security/views.py b/flask_security/views.py index e9eb2ed3..f1d534cb 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -33,9 +33,9 @@ # Convenient references -_security = LocalProxy(lambda: app.security) +_security = LocalProxy(lambda: app.extensions['security']) -_datastore = LocalProxy(lambda: app.security.datastore) +_datastore = LocalProxy(lambda: _security.datastore) _logger = LocalProxy(lambda: app.logger) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 7fa4fb18..87a51bee 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -279,7 +279,7 @@ def test_expired_confirmation_token_sends_email(self): self.assertIn(e, outbox[0].html) self.assertNotIn(token, outbox[0].html) - expire_text = self.app.security.confirm_email_within + expire_text = self.AUTH_CONFIG['SECURITY_CONFIRM_EMAIL_WITHIN'] text = 'You did not confirm your account within %s' % expire_text self.assertIn(text, r.data) From 3899980b2be36c62f6ff6f488acee198f94500f5 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 16 Jul 2012 19:08:22 -0400 Subject: [PATCH 102/234] Not using these --- flask_security/core.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index f4d82199..d0a632dd 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -207,9 +207,6 @@ class Security(object): :param datastore: An instance of a user datastore. """ def __init__(self, app=None, datastore=None, **kwargs): - self.app = app - self.datastore = datastore - if app is not None and datastore is not None: self.init_app(app, datastore, **kwargs) From d5c6e4eb58a76f0d253e2eb860b4709795e80b04 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 16 Jul 2012 19:11:18 -0400 Subject: [PATCH 103/234] Update example/test app --- example/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/example/app.py b/example/app.py index 69e0b6ed..8035cb6f 100644 --- a/example/app.py +++ b/example/app.py @@ -22,7 +22,7 @@ def create_roles(): for role in ('admin', 'editor', 'author'): - current_app.security.datastore.create_role(name=role) + current_app.extensions['security'].datastore.create_role(name=role) def create_users(): @@ -31,7 +31,7 @@ def create_users(): ('dave@lp.com', 'password', ['admin', 'editor'], True), ('jill@lp.com', 'password', ['author'], True), ('tiya@lp.com', 'password', [], False)): - current_app.security.datastore.create_user( + current_app.extensions['security'].datastore.create_user( email=u[0], password=u[1], roles=u[2], active=u[3]) From e23581e260ae21e67eae438cd38f0fb77ea9a6e7 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 16 Jul 2012 19:34:33 -0400 Subject: [PATCH 104/234] Polish --- flask_security/core.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index d0a632dd..9b776ae3 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -232,14 +232,11 @@ def init_app(self, app, datastore, register_blueprint=True): url_prefix=cv('URL_PREFIX', app=app)) app.register_blueprint(bp) - if not hasattr(app, 'extensions'): - app.extensions = {} - kwargs = {} for key, value in get_config(app).items(): kwargs[key.lower()] = value - app.extensions['security'] = _SecurityState( + state = _SecurityState( app=app, datastore=datastore, auth_provider=AuthenticationProvider(), @@ -251,6 +248,11 @@ def init_app(self, app, datastore, register_blueprint=True): token_auth_serializer=_get_token_auth_serializer(app), **kwargs) + if not hasattr(app, 'extensions'): + app.extensions = {} + + app.extensions['security'] = state + class AuthenticationProvider(object): """The default authentication provider implementation.""" From 1c606a242a95b2b15fd528a50bc0dafe4e26f274 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 16 Jul 2012 19:40:34 -0400 Subject: [PATCH 105/234] Adjust state --- example/app.py | 4 ++-- flask_security/core.py | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/example/app.py b/example/app.py index 8035cb6f..69e0b6ed 100644 --- a/example/app.py +++ b/example/app.py @@ -22,7 +22,7 @@ def create_roles(): for role in ('admin', 'editor', 'author'): - current_app.extensions['security'].datastore.create_role(name=role) + current_app.security.datastore.create_role(name=role) def create_users(): @@ -31,7 +31,7 @@ def create_users(): ('dave@lp.com', 'password', ['admin', 'editor'], True), ('jill@lp.com', 'password', ['author'], True), ('tiya@lp.com', 'password', [], False)): - current_app.extensions['security'].datastore.create_user( + current_app.security.datastore.create_user( email=u[0], password=u[1], roles=u[2], active=u[3]) diff --git a/flask_security/core.py b/flask_security/core.py index 9b776ae3..50a1b719 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -208,7 +208,7 @@ class Security(object): """ def __init__(self, app=None, datastore=None, **kwargs): if app is not None and datastore is not None: - self.init_app(app, datastore, **kwargs) + self._state = self.init_app(app, datastore, **kwargs) def init_app(self, app, datastore, register_blueprint=True): """Initializes the Flask-Security extension for the specified @@ -253,6 +253,11 @@ def init_app(self, app, datastore, register_blueprint=True): app.extensions['security'] = state + return state + + def __getattr__(self, name): + return getattr(self._state, name, None) + class AuthenticationProvider(object): """The default authentication provider implementation.""" From 68dd972bfaa916a76b702d45f8b4fa2807291ffa Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 18 Jul 2012 13:27:30 -0400 Subject: [PATCH 106/234] Add more secure password storage via salt value and hmac --- docs/index.rst | 2 +- example/app.py | 4 ++-- flask_security/core.py | 8 ++++++-- flask_security/datastore.py | 5 ++++- flask_security/decorators.py | 4 +++- flask_security/recoverable.py | 7 +++++-- flask_security/utils.py | 20 +++++++++++++++++++- tests/functional_tests.py | 3 +++ 8 files changed, 43 insertions(+), 10 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 20ab865d..fd25cf24 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -114,7 +114,7 @@ using SQLAlchemy.:: class User(db.Model, UserMixin): id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(255), unique=True) - password = db.Column(db.String(120)) + password = db.Column(db.String(255)) remember_token = db.Column(db.String(255)) active = db.Column(db.Boolean()) authentication_token = db.Column(db.String(255)) diff --git a/example/app.py b/example/app.py index 69e0b6ed..e739c6de 100644 --- a/example/app.py +++ b/example/app.py @@ -136,7 +136,7 @@ class Role(db.Model, RoleMixin): class User(db.Model, UserMixin): id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(255), unique=True) - password = db.Column(db.String(120)) + password = db.Column(db.String(255)) remember_token = db.Column(db.String(255)) last_login_at = db.Column(db.DateTime()) current_login_at = db.Column(db.DateTime()) @@ -180,7 +180,7 @@ class Role(db.Document, RoleMixin): class User(db.Document, UserMixin): email = db.StringField(unique=True, max_length=255) - password = db.StringField(required=True, max_length=120) + password = db.StringField(required=True, max_length=255) remember_token = db.StringField(max_length=255) last_login_at = db.DateTimeField() current_login_at = db.DateTimeField() diff --git a/flask_security/core.py b/flask_security/core.py index 50a1b719..f8612b6f 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -21,7 +21,7 @@ from . import views, exceptions from .confirmable import requires_confirmation -from .utils import config_value as cv, get_config +from .utils import config_value as cv, get_config, verify_password # Convenient references _security = LocalProxy(lambda: current_app.extensions['security']) @@ -32,6 +32,8 @@ 'URL_PREFIX': None, 'FLASH_MESSAGES': True, 'PASSWORD_HASH': 'plaintext', + 'PASSWORD_SALT': None, + 'PASSWORD_HMAC': False, 'AUTH_URL': '/auth', 'LOGOUT_URL': '/logout', 'REGISTER_URL': '/register', @@ -308,7 +310,9 @@ def do_authenticate(self, username_or_email, password): raise exceptions.BadCredentialsError('Account requires confirmation') # compare passwords - if _security.pwd_context.verify(password, user.password): + if verify_password(password, user.password, + salt=_security.password_salt, + use_hmac=_security.password_hmac): return user # bad match diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 98d52139..e709cb09 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -113,7 +113,10 @@ def _prepare_create_user_args(self, kwargs): pw = kwargs['password'] if not pwd_context.identify(pw): - kwargs['password'] = pwd_context.encrypt(pw) + pwd_hash = utils.encrypt_password(pw, + salt=_security.password_salt, + use_hmac=_security.password_hmac) + kwargs['password'] = pwd_hash kwargs['remember_token'] = utils.get_remember_token(kwargs['email'], kwargs['password']) diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 93bfe8a7..400cb0e1 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -77,7 +77,9 @@ def _check_http_auth(): except UserNotFoundError: return False - rv = _security.pwd_context.verify(auth.password, user.password) + rv = utils.verify_password(auth.password, user.password, + salt=_security.password_salt, + use_hmac=_security.password_hmac) if rv: identity_changed.send(current_app._get_current_object(), diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 48fd1b55..8098d9b5 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -16,7 +16,7 @@ from .exceptions import ResetPasswordError, UserNotFoundError from .signals import password_reset, password_reset_requested, \ reset_instructions_sent -from .utils import send_mail, get_max_age, md5, get_message +from .utils import send_mail, get_max_age, md5, get_message, encrypt_password # Convenient references @@ -85,7 +85,10 @@ def reset_by_token(token, password): if md5(user.password) != data[1]: raise UserNotFoundError() - user.password = _security.pwd_context.encrypt(password) + user.password = encrypt_password(password, + salt=_security.password_salt, + use_hmac=_security.password_hmac) + print user.password _datastore._save_model(user) send_password_reset_notice(user) diff --git a/flask_security/utils.py b/flask_security/utils.py index fdbcf398..a1a1d0ce 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -11,6 +11,7 @@ import base64 import hashlib +import hmac import os from contextlib import contextmanager from datetime import datetime, timedelta @@ -25,6 +26,23 @@ # Convenient references _security = LocalProxy(lambda: current_app.extensions['security']) +_pwd_context = LocalProxy(lambda: _security.pwd_context) + + +def get_hmac(msg, salt=None, digestmod=None): + digestmod = digestmod or hashlib.sha512 + return base64.b64encode(hmac.new(salt, msg, digestmod).digest()) + + +def verify_password(password, password_hash, salt=None, use_hmac=False): + hmac_value = get_hmac(password, salt) if use_hmac else password + return _pwd_context.verify(hmac_value, password_hash) + + +def encrypt_password(password, salt=None, use_hmac=False): + hmac_value = get_hmac(password, salt) if use_hmac else password + return _pwd_context.encrypt(hmac_value) + def md5(data): return hashlib.md5(data).hexdigest() @@ -202,4 +220,4 @@ def _on(request, app): try: yield reset_requests finally: - password_reset_requested.disconnect(_on) + password_reset_requested.disconnect(_on) \ No newline at end of file diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 87a51bee..f0c0b621 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -157,6 +157,9 @@ def test_custom_http_auth_realm(self): class ConfiguredSecurityTests(SecurityTest): AUTH_CONFIG = { + 'SECURITY_PASSWORD_HASH': 'bcrypt', + 'SECURITY_PASSWORD_SALT': 'so-salty', + 'SECURITY_PASSWORD_HMAC': True, 'SECURITY_REGISTERABLE': True, 'SECURITY_AUTH_URL': '/custom_auth', 'SECURITY_LOGOUT_URL': '/custom_logout', From dbd0b7b23bb2130f4528561b82d1b0b838ba3f34 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 19 Jul 2012 12:20:04 -0400 Subject: [PATCH 107/234] House keeping --- LICENSE | 2 +- README.rst | 8 ++------ docs/index.rst | 9 ++------- setup.py | 15 ++++++++------- 4 files changed, 13 insertions(+), 21 deletions(-) diff --git a/LICENSE b/LICENSE index bbf6b578..2b1a4f0b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (C) 2012 by Matt Wright +Copyright (C) 2012 by Matthew Wright Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/README.rst b/README.rst index 31b0bf6b..caf3b42d 100644 --- a/README.rst +++ b/README.rst @@ -3,12 +3,8 @@ Flask-Security .. image:: https://secure.travis-ci.org/mattupstate/flask-security.png?branch=develop -Simple security for Flask applications combining Flask-Login, Flask-Principal, -Flask-WTF, passlib, and your choice of datastore. Currently SQLAlchemy via -Flask-SQLAlchemy and MongoEngine via Flask-MongoEngine are supported out of the -box. You will need to install the necessary Flask extensions that you'll be -using. Additionally, you may need to install an encryption library such as -py-bcrypt to support bcrypt passwords. +Flask-Security is a Flask extension that aims to add quick and simple security +to your Flask applications. Resources --------- diff --git a/docs/index.rst b/docs/index.rst index fd25cf24..0991e128 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,10 +1,4 @@ - -Flask-Security -============== - -.. module:: flask_security - -Quick and simple security for Flask applications. +.. include:: ../README.rst Contents @@ -322,6 +316,7 @@ signals. Changelog ========= + .. toctree:: :maxdepth: 2 diff --git a/setup.py b/setup.py index 3f9f2f04..79ea6ff6 100644 --- a/setup.py +++ b/setup.py @@ -1,16 +1,17 @@ """ 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. +to your Flask applications. -Links -````` +Resources +--------- -* `documentation `_ -* `source `_ -* `development version +* `Documentation `_ +* `Issue Tracker `_ +* `Source `_ +* `Development Version `_ """ From aee3d30e5005fd270f5602389789e19ecc026fa6 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 19 Jul 2012 17:04:48 -0400 Subject: [PATCH 108/234] Remove required flask-mail dependency. Let users install it themselves if features require it. Also need to wait till newer version is released --- setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.py b/setup.py index 79ea6ff6..6265e8be 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,6 @@ 'Flask-Login>=0.1.3', 'Flask-Principal>=0.3', 'Flask-WTF>=0.5.4', - 'Flask-Mail>=0.6.1', 'itsdangerous>=0.15', 'passlib>=1.5.3', ], From eb388ad04fa8a51193841a846dc0dec04b0d4f55 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 19 Jul 2012 17:05:06 -0400 Subject: [PATCH 109/234] Add RuntimeError to send_mail function --- flask_security/utils.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/flask_security/utils.py b/flask_security/utils.py index a1a1d0ce..24a54b91 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -168,6 +168,14 @@ def send_mail(subject, recipient, template, context=None): :param template: The name of the email template :param context: The context to render the template with """ + mail = current_app.extensions.get('mail', None) + current_app.logger.debug('%s' % current_app.extensions) + + if mail is None: + raise RuntimeError('You need to install and configure the ' + 'Flask-Mail extension in order to send ' + 'emails with Flask-Security') + from flask.ext.mail import Message context = context or {} @@ -180,7 +188,7 @@ def send_mail(subject, recipient, template, context=None): msg.body = render_template('%s/%s.txt' % (base, template), **context) msg.html = render_template('%s/%s.html' % (base, template), **context) - current_app.mail.send(msg) + mail.send(msg) @contextmanager From 882969229587608ea93337ee2deacc3948ff4994 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 19 Jul 2012 17:05:13 -0400 Subject: [PATCH 110/234] Refactor state management --- flask_security/core.py | 46 +++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index f8612b6f..6e83d90b 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -209,10 +209,13 @@ class Security(object): :param datastore: An instance of a user datastore. """ def __init__(self, app=None, datastore=None, **kwargs): + self.app = app + self.datastore = datastore + if app is not None and datastore is not None: self._state = self.init_app(app, datastore, **kwargs) - def init_app(self, app, datastore, register_blueprint=True): + def init_app(self, app, datastore=None, register_blueprint=True): """Initializes the Flask-Security extension for the specified application and datastore implentation. @@ -234,28 +237,39 @@ def init_app(self, app, datastore, register_blueprint=True): url_prefix=cv('URL_PREFIX', app=app)) app.register_blueprint(bp) + state = self._get_state(app, datastore or self.datastore) + + if not hasattr(app, 'extensions'): + app.extensions = {} + + app.extensions['security'] = state + + def _get_state(self, app, datastore): + assert app is not None + assert datastore is not None + kwargs = {} + for key, value in get_config(app).items(): kwargs[key.lower()] = value - state = _SecurityState( - app=app, - datastore=datastore, - auth_provider=AuthenticationProvider(), - login_manager=_get_login_manager(app), - principal=_get_principal(app), - pwd_context=_get_pwd_context(app), - reset_serializer=_get_reset_serializer(app), - confirm_serializer=_get_confirm_serializer(app), - token_auth_serializer=_get_token_auth_serializer(app), - **kwargs) + for key, value in [ + ('app', app), + ('datastore', datastore), + ('auth_provider', AuthenticationProvider()), + ('login_manager', _get_login_manager(app)), + ('principal', _get_principal(app)), + ('pwd_context', _get_pwd_context(app)), + ('token_auth_serializer', _get_token_auth_serializer(app))]: + kwargs[key] = value - if not hasattr(app, 'extensions'): - app.extensions = {} + kwargs['reset_serializer'] = ( + _get_reset_serializer(app) if kwargs['recoverable'] else None) - app.extensions['security'] = state + kwargs['confirm_serializer'] = ( + _get_confirm_serializer(app) if kwargs['confirmable'] else None) - return state + return _SecurityState(**kwargs) def __getattr__(self, name): return getattr(self._state, name, None) From a89f43a1b76dedf2403c45329402f539cd008f84 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 19 Jul 2012 17:08:06 -0400 Subject: [PATCH 111/234] Fix build --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index de5567fe..dcc15386 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,6 +10,7 @@ install: - "if [[ $TRAVIS_PYTHON_VERSION != '2.7' ]]; then pip install importlib simplejson --quiet --use-mirrors; fi" - "if [[ $TRAVIS_PYTHON_VERSION == '2.5' ]]; then pip install mongoengine==0.6.5 --quiet --use-mirrors; fi" - pip install nose Flask-SQLAlchemy Flask-MongoEngine py-bcrypt MySQL-python --quiet --use-mirrors + - pip install https://github.com/rduplain/flask-mail/tarball/master before_script: - mysql -e 'create database flask_security_test;' From 78b2eb8b18358af31e19e77b3ea0cfb5c1119894 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 19 Jul 2012 17:39:47 -0400 Subject: [PATCH 112/234] Remove stray print statement --- flask_security/recoverable.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 8098d9b5..6b4e0a38 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -88,7 +88,7 @@ def reset_by_token(token, password): user.password = encrypt_password(password, salt=_security.password_salt, use_hmac=_security.password_hmac) - print user.password + _datastore._save_model(user) send_password_reset_notice(user) From 39670ac84ee4666400577b974e6ed0443aecb534 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 19 Jul 2012 17:39:55 -0400 Subject: [PATCH 113/234] Fix state a bit --- flask_security/core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flask_security/core.py b/flask_security/core.py index 6e83d90b..fbef5fce 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -244,6 +244,8 @@ def init_app(self, app, datastore=None, register_blueprint=True): app.extensions['security'] = state + return state + def _get_state(self, app, datastore): assert app is not None assert datastore is not None From a9727ceaa6266f7a93900260bca5ab1a8f5bdffa Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 23 Jul 2012 18:28:13 -0400 Subject: [PATCH 114/234] Refactor login_user and logout_user to be a utility method, thus it can be reused if necessary --- docs/index.rst | 14 +- example/security.py | 316 ------------------------------------- flask_security/__init__.py | 3 +- flask_security/tokens.py | 51 ------ flask_security/utils.py | 61 ++++++- flask_security/views.py | 51 +----- 6 files changed, 73 insertions(+), 423 deletions(-) delete mode 100644 example/security.py delete mode 100644 flask_security/tokens.py diff --git a/docs/index.rst b/docs/index.rst index 0991e128..970e134e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -34,13 +34,13 @@ your Flask application. They include: Many of these features are made possible by integrating various Flask extensions and libraries. They include: -1. Flask-Login -2. Flask-Mail -3. Flask-Principal -4. Flask-Script -5. Flask-WTF -6. itsdangerous -7. passlib +1. `Flask-Login `_ +2. `Flask-Mail `_ +3. `Flask-Principal `_ +4. `Flask-Script `_ +5. `Flask-WTF `_ +6. `itsdangerous `_ +7. `passlib `_ Additionally, it assumes you'll be using a common library for your database connections and model definitions. Flask-Security thus supports SQLAlchemy and diff --git a/example/security.py b/example/security.py deleted file mode 100644 index fe684d87..00000000 --- a/example/security.py +++ /dev/null @@ -1,316 +0,0 @@ -# -*- coding: utf-8 -*- - -""" - Flask-Security - ~~~~~~~~~~~~~~ - - This module was generated by Flask-Security to give developers greater - control over the various security mechanisms. For more information about - using this feature see: - - TODO: Documentation URL -""" - - -from datetime import datetime - -from flask import current_app as app, redirect, request, session, \ - render_template, jsonify, Blueprint -from flask.ext.login import login_user, logout_user -from flask.ext.principal import Identity, AnonymousIdentity, identity_changed -from werkzeug.datastructures import MultiDict -from werkzeug.local import LocalProxy - -from flask_security.confirmable import confirm_by_token, reset_confirmation_token -from flask_security.decorators import login_required -from flask_security.exceptions import ConfirmationError, BadCredentialsError, \ - ResetPasswordError -from flask_security.forms import LoginForm, RegisterForm, ForgotPasswordForm, \ - ResetPasswordForm, ResendConfirmationForm -from flask_security.recoverable import reset_by_token, \ - reset_password_reset_token -from flask_security.signals import user_registered -from flask_security.tokens import generate_authentication_token -from flask_security.utils import get_url, get_post_login_redirect, do_flash, \ - get_remember_token, get_message, config_value - - -# Convenient references -_security = LocalProxy(lambda: app.security) - -_datastore = LocalProxy(lambda: app.security.datastore) - -_logger = LocalProxy(lambda: app.logger) - - -def _do_login(user, remember=True): - """Performs the login and sends the appropriate signal.""" - - if not login_user(user, remember): - return False - - if user.authentication_token is None: - user.authentication_token = generate_authentication_token(user) - - if remember: - user.remember_token = get_remember_token(user.email, user.password) - - if _security.trackable: - old_current, new_current = user.current_login_at, datetime.utcnow() - user.last_login_at = old_current or new_current - user.current_login_at = new_current - - old_current, new_current = user.current_login_ip, request.remote_addr - user.last_login_ip = old_current or new_current - user.current_login_ip = new_current - - user.login_count = user.login_count + 1 if user.login_count else 0 - - _datastore._save_model(user) - - identity_changed.send(app._get_current_object(), - identity=Identity(user.id)) - - _logger.debug('User %s logged in' % user) - return True - - -def _json_auth_ok(user): - return jsonify({ - "meta": { - "code": 200 - }, - "response": { - "user": { - "id": str(user.id), - "authentication_token": user.authentication_token - } - } - }) - - -def _json_auth_error(msg): - resp = jsonify({ - "meta": { - "code": 400 - }, - "response": { - "error": msg - } - }) - resp.status_code = 400 - return resp - - -def authenticate(): - """View function which handles an authentication request.""" - - form = LoginForm(MultiDict(request.json) if request.json else request.form) - - try: - user = _security.auth_provider.authenticate(form) - - if _do_login(user, remember=form.remember.data): - if request.json: - return _json_auth_ok(user) - - return redirect(get_post_login_redirect()) - - raise BadCredentialsError('Account is disabled') - - except BadCredentialsError, e: - msg = str(e) - - _logger.debug('Unsuccessful authentication attempt: %s' % msg) - - if request.json: - return _json_auth_error(msg) - - do_flash(msg, 'error') - - return redirect(request.referrer or _security.login_manager.login_view) - - -def logout(): - """View function which handles a logout request.""" - - for key in ('identity.name', 'identity.auth_type'): - session.pop(key, None) - - identity_changed.send(app._get_current_object(), - identity=AnonymousIdentity()) - - logout_user() - _logger.debug('User logged out') - - return redirect(request.args.get('next', None) or - _security.post_logout_view) - - -def register_user(): - """View function which handles a registration request.""" - - form = RegisterForm(csrf_enabled=not app.testing) - - if form.validate_on_submit(): - # Create user - u = _datastore.create_user(**form.to_dict()) - - # Send confirmation instructions if necessary - t = reset_confirmation_token(u) if _security.confirmable else None - - data = dict(user=u, confirm_token=t) - user_registered.send(data, app=app._get_current_object()) - - _logger.debug('User %s registered' % u) - - # Login the user if allowed - if not _security.confirmable or _security.login_without_confirmation: - _do_login(u) - - return redirect(_security.post_register_view or - _security.post_login_view) - - return render_template('security/registrations/new.html', - register_user_form=form) - - -def send_confirmation(): - form = ResendConfirmationForm() - - if form.validate_on_submit(): - user = _datastore.find_user(email=form.email.data) - - reset_confirmation_token(user) - - _logger.debug('%s request confirmation instructions' % user) - - msg, cat = get_message('CONFIRMATION_REQUEST', email=user.email) - - do_flash(msg, cat) - - else: - for key, value in form.errors.items(): - do_flash(value[0], 'error') - - return render_template('security/confirmations/new.html', - reset_confirmation_form=form) - - -def confirm_account(token): - """View function which handles a account confirmation request.""" - try: - user = confirm_by_token(token) - _logger.debug('%s confirmed their account' % user) - - except ConfirmationError, e: - msg, cat = str(e), 'error' - - _logger.debug('Confirmation error: ' + msg) - - if e.user: - reset_confirmation_token(e.user) - - msg, cat = get_message('CONFIRMATION_EXPIRED', - within=_security.confirm_email_within, - email=e.user.email) - - do_flash(msg, cat) - - return redirect(get_url(_security.confirm_error_view)) - - do_flash(get_message('ACCOUNT_CONFIRMED')) - - return redirect(_security.post_confirm_view or _security.post_login_view) - - -def forgot_password(): - """View function that handles a forgotten password request.""" - - form = ForgotPasswordForm(csrf_enabled=not app.testing) - - if form.validate_on_submit(): - user = _datastore.find_user(**form.to_dict()) - - reset_password_reset_token(user) - - _logger.debug('%s requested to reset their password' % user) - - msg, cat = get_message('PASSWORD_RESET_REQUEST', email=user.email) - - do_flash(msg, cat) - - return redirect(_security.post_forgot_view) - - else: - _logger.debug('A reset password request was made for %s but ' - 'that email does not exist.' % form.email.data) - - for key, value in form.errors.items(): - do_flash(value[0], 'error') - - return render_template('security/passwords/new.html', - forgot_password_form=form) - - -def reset_password(token): - """View function that handles a reset password request.""" - - form = ResetPasswordForm(csrf_enabled=not app.testing) - - if form.validate_on_submit(): - try: - user = reset_by_token(token=token, **form.to_dict()) - _logger.debug('%s reset their password' % user) - - except ResetPasswordError, e: - msg, cat = str(e), 'error' - - _logger.debug('Password reset error: ' + msg) - - if e.user: - reset_password_reset_token(e.user) - - msg, cat = get_message('PASSWORD_RESET_EXPIRED', - within=_security.reset_password_within, - email=e.user.email) - - do_flash(msg, cat) - - return render_template('security/passwords/edit.html', - reset_password_form=form, - password_reset_token=token) - - -def create_blueprint(app, name, import_name, **kwargs): - bp = Blueprint(name, import_name, **kwargs) - - bp.route(config_value('AUTH_URL', app=app), - methods=['POST'], - endpoint='authenticate')(authenticate) - - bp.route(config_value('LOGOUT_URL', app=app), - endpoint='logout')(login_required(logout)) - - if config_value('REGISTERABLE', app=app): - bp.route(config_value('REGISTER_URL', app=app), - methods=['GET', 'POST'], - endpoint='register')(register_user) - - if config_value('RECOVERABLE', app=app): - bp.route(config_value('RESET_URL', app=app), - methods=['GET', 'POST'], - endpoint='forgot_password')(forgot_password) - bp.route(config_value('RESET_URL', app=app) + '/', - methods=['GET', 'POST'], - endpoint='reset_password')(reset_password) - - if config_value('CONFIRMABLE', app=app): - bp.route(config_value('CONFIRM_URL', app=app), - methods=['GET', 'POST'], - endpoint='send_confirmation')(send_confirmation) - bp.route(config_value('CONFIRM_URL', app=app) + '/', - methods=['GET', 'POST'], - endpoint='confirm_account')(confirm_account) - - return bp diff --git a/flask_security/__init__.py b/flask_security/__init__.py index cca9f3e5..ceedf93c 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -10,8 +10,6 @@ :license: MIT, see LICENSE for more details. """ -from flask.ext.login import login_user, logout_user - from .core import Security, RoleMixin, UserMixin, AnonymousUser, \ AuthenticationProvider, current_user from .datastore import SQLAlchemyUserDatastore, MongoEngineUserDatastore @@ -22,3 +20,4 @@ from .signals import confirm_instructions_sent, password_reset, \ password_reset_requested, reset_instructions_sent, user_confirmed, \ user_registered +from .utils import login_user, logout_user diff --git a/flask_security/tokens.py b/flask_security/tokens.py deleted file mode 100644 index f896469e..00000000 --- a/flask_security/tokens.py +++ /dev/null @@ -1,51 +0,0 @@ -# -*- coding: utf-8 -*- -""" - flask.ext.security.tokens - ~~~~~~~~~~~~~~~~~~~~~~~~~ - - Flask-Security tokens module - - :copyright: (c) 2012 by Matt Wright. - :license: MIT, see LICENSE for more details. -""" - -from flask import current_app as app -from werkzeug.local import LocalProxy - -from .utils import md5 - - -# Convenient references -_security = LocalProxy(lambda: app.extensions['security']) - -_datastore = LocalProxy(lambda: _security.datastore) - - -def generate_authentication_token(user): - """Generates a unique authentication token for the specified user. - - :param user: The user to work with - """ - data = [str(user.id), md5(user.email)] - return _security.token_auth_serializer.dumps(data) - - -def reset_authentication_token(user): - """Resets a user's authentication token and returns the new token value. - - :param user: The user to work with - """ - token = generate_authentication_token(user) - user.authentication_token = token - _datastore._save_model(user) - return token - - -def ensure_authentication_token(user): - """Ensures that a user has an authentication token. If the user has an - authentication token already, nothing is performed. - - :param user: The user to work with - """ - if not user.authentication_token: - return reset_authentication_token(user) diff --git a/flask_security/utils.py b/flask_security/utils.py index 24a54b91..f8a095e7 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -17,7 +17,9 @@ from datetime import datetime, timedelta from flask import url_for, flash, current_app, request, session, render_template -from flask.ext.login import make_secure_token +from flask.ext.login import make_secure_token, login_user as _login_user, \ + logout_user as _logout_user +from flask.ext.principal import Identity, AnonymousIdentity, identity_changed from werkzeug.local import LocalProxy from .signals import user_registered, password_reset_requested @@ -26,8 +28,56 @@ # Convenient references _security = LocalProxy(lambda: current_app.extensions['security']) +_datastore = LocalProxy(lambda: _security.datastore) + _pwd_context = LocalProxy(lambda: _security.pwd_context) +_logger = LocalProxy(lambda: current_app.logger) + + +def login_user(user, remember=True): + """Performs the login and sends the appropriate signal.""" + + if not _login_user(user, remember): + return False + + if user.authentication_token is None: + from .tokens import generate_authentication_token + + user.authentication_token = generate_authentication_token(user) + + if remember: + user.remember_token = get_remember_token(user.email, user.password) + + if _security.trackable: + old_current, new_current = user.current_login_at, datetime.utcnow() + user.last_login_at = old_current or new_current + user.current_login_at = new_current + + old_current, new_current = user.current_login_ip, request.remote_addr + user.last_login_ip = old_current or new_current + user.current_login_ip = new_current + + user.login_count = user.login_count + 1 if user.login_count else 0 + + _datastore._save_model(user) + + identity_changed.send(current_app._get_current_object(), + identity=Identity(user.id)) + + _logger.debug('User %s logged in' % user) + return True + + +def logout_user(): + for key in ('identity.name', 'identity.auth_type'): + session.pop(key, None) + + identity_changed.send(current_app._get_current_object(), + identity=AnonymousIdentity()) + + _logout_user() + def get_hmac(msg, salt=None, digestmod=None): digestmod = digestmod or hashlib.sha512 @@ -44,6 +94,15 @@ def encrypt_password(password, salt=None, use_hmac=False): return _pwd_context.encrypt(hmac_value) +def generate_authentication_token(user): + """Generates a unique authentication token for the specified user. + + :param user: The user to work with + """ + data = [str(user.id), md5(user.email)] + return _security.token_auth_serializer.dumps(data) + + def md5(data): return hashlib.md5(data).hexdigest() diff --git a/flask_security/views.py b/flask_security/views.py index f1d534cb..a0eb4150 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -9,12 +9,9 @@ :license: MIT, see LICENSE for more details. """ -from datetime import datetime - from flask import current_app as app, redirect, request, session, \ render_template, jsonify, Blueprint -from flask.ext.login import login_user, logout_user -from flask.ext.principal import Identity, AnonymousIdentity, identity_changed +from flask.ext.principal import AnonymousIdentity, identity_changed from werkzeug.datastructures import MultiDict from werkzeug.local import LocalProxy @@ -27,9 +24,8 @@ from flask_security.recoverable import reset_by_token, \ reset_password_reset_token from flask_security.signals import user_registered -from flask_security.tokens import generate_authentication_token from flask_security.utils import get_url, get_post_login_redirect, do_flash, \ - get_remember_token, get_message, config_value + get_message, config_value, login_user, logout_user # Convenient references @@ -40,38 +36,6 @@ _logger = LocalProxy(lambda: app.logger) -def _do_login(user, remember=True): - """Performs the login and sends the appropriate signal.""" - - if not login_user(user, remember): - return False - - if user.authentication_token is None: - user.authentication_token = generate_authentication_token(user) - - if remember: - user.remember_token = get_remember_token(user.email, user.password) - - if _security.trackable: - old_current, new_current = user.current_login_at, datetime.utcnow() - user.last_login_at = old_current or new_current - user.current_login_at = new_current - - old_current, new_current = user.current_login_ip, request.remote_addr - user.last_login_ip = old_current or new_current - user.current_login_ip = new_current - - user.login_count = user.login_count + 1 if user.login_count else 0 - - _datastore._save_model(user) - - identity_changed.send(app._get_current_object(), - identity=Identity(user.id)) - - _logger.debug('User %s logged in' % user) - return True - - def _json_auth_ok(user): return jsonify({ "meta": { @@ -107,7 +71,7 @@ def authenticate(): try: user = _security.auth_provider.authenticate(form) - if _do_login(user, remember=form.remember.data): + if login_user(user, remember=form.remember.data): if request.json: return _json_auth_ok(user) @@ -131,13 +95,8 @@ def authenticate(): def logout(): """View function which handles a logout request.""" - for key in ('identity.name', 'identity.auth_type'): - session.pop(key, None) - - identity_changed.send(app._get_current_object(), - identity=AnonymousIdentity()) - logout_user() + _logger.debug('User logged out') return redirect(request.args.get('next', None) or @@ -163,7 +122,7 @@ def register_user(): # Login the user if allowed if not _security.confirmable or _security.login_without_confirmation: - _do_login(u) + login_user(u) return redirect(_security.post_register_view or _security.post_login_view) From 7355a741b3ac131e1dcb9a057289644689d6cbf4 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 23 Jul 2012 18:34:36 -0400 Subject: [PATCH 115/234] Fix build --- flask_security/utils.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/flask_security/utils.py b/flask_security/utils.py index f8a095e7..50e59831 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -42,8 +42,6 @@ def login_user(user, remember=True): return False if user.authentication_token is None: - from .tokens import generate_authentication_token - user.authentication_token = generate_authentication_token(user) if remember: From 782d6d7e89ea307a34a7c19c82c56fc2a9505b48 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 23 Jul 2012 18:35:37 -0400 Subject: [PATCH 116/234] PEP 8... --- flask_security/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/utils.py b/flask_security/utils.py index 50e59831..ef840065 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -285,4 +285,4 @@ def _on(request, app): try: yield reset_requests finally: - password_reset_requested.disconnect(_on) \ No newline at end of file + password_reset_requested.disconnect(_on) From 4238a7f70453c1e5064dd620566eff29a5abd2a2 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 14 Aug 2012 12:14:08 -0400 Subject: [PATCH 117/234] Update docs a bit --- docs/index.rst | 56 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 970e134e..6c7a9f10 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -144,10 +144,60 @@ using SQLAlchemy.:: .. _models: -Data Models ------------ +Models +====== + +Flask-Security assumes you'll be using libraries such as SQLAlchemy or +MongoEngine to define a data model that includes a `User` and `Role` model. The +fields on your models must follow a particular convention depending on the +functionality your app requires. Aside from this, you're free to add any +additional fields to your model(s) if you want. At the bear minimum your `User` +and `Role` model should include the following fields: + +**User** + +* id +* email +* password +* remember_token +* active +* authentication_token + +**Role** + +* id +* name +* description + + +Additional Functionality +------------------------ + +Depending on the application's configuration, additional fields may need to be +added to your `User` model. + +Confirmable +^^^^^^^^^^^ + +If you enable account confirmation by setting your application's +`SECURITY_CONFIRMABLE` configuration value to `True` your `User` model will +require the following additional field: + +* confirmed_at + +Trackable +^^^^^^^^^ + +If you enable user tracking by setting your application's `SECURITY_TRACKABLE` +configuration value to `True` your `User` model will require the following +additional fields: + +* last_login_at +* current_login_at +* last_login_ip +* current_login_ip +* login_count -TODO: Describe models and required fields for various features .. _flask-script-commands: From 49bebcdd7bf136fa81ad0f2d2497c8e2e045147b Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 14 Aug 2012 12:14:19 -0400 Subject: [PATCH 118/234] Code cleanup --- flask_security/confirmable.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 9c703a8c..bea58424 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -54,19 +54,9 @@ def generate_confirmation_token(user): return _security.confirm_serializer.dumps(data) -def should_confirm_email(fn): - """Handy decorator that returns early if confirmation should not occur.""" - def wrapped(*args, **kwargs): - if _security.confirmable: - return fn(*args, **kwargs) - return False - return wrapped - - -@should_confirm_email def requires_confirmation(user): """Returns `True` if the user requires confirmation.""" - return user.confirmed_at == None + return user.confirmed_at == None if _security.confirmable else False def confirm_by_token(token): From f6bb01545d47d338b2ce889d55cc725f3ef6aea9 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 14 Aug 2012 13:47:16 -0400 Subject: [PATCH 119/234] Add more test coverage --- tests/__init__.py | 6 +++--- tests/functional_tests.py | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 09bbf932..13b34c96 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -32,9 +32,9 @@ def register(self, email, password='password'): data = dict(email=email, password=password, password_confirm=password) return self.client.post('/register', data=data, follow_redirects=True) - def authenticate(self, email="matt@lp.com", password="password", endpoint=None): - data = dict(email=email, password=password) - return self._post(endpoint or '/auth', data=data) + def authenticate(self, email="matt@lp.com", password="password", endpoint=None, **kwargs): + data = dict(email=email, password=password, remember='y') + return self._post(endpoint or '/auth', data=data, **kwargs) def json_authenticate(self, email="matt@lp.com", password="password", endpoint=None): data = """ diff --git a/tests/functional_tests.py b/tests/functional_tests.py index f0c0b621..cba617ae 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -12,11 +12,19 @@ from flask.ext.security.utils import capture_registrations, \ capture_reset_password_requests +from werkzeug.utils import parse_cookie from example import app from tests import SecurityTest +def get_cookies(rv): + cookies = {} + for value in rv.headers.get_all("Set-Cookie"): + cookies.update(parse_cookie(value)) + return cookies + + class DefaultSecurityTests(SecurityTest): def test_login_view(self): @@ -153,6 +161,22 @@ def test_custom_http_auth_realm(self): self.assertIn('WWW-Authenticate', r.headers) self.assertEquals('Basic realm="My Realm"', r.headers['WWW-Authenticate']) + def test_user_deleted_during_session_reverts_to_anonymous_user(self): + self.authenticate() + + with self.app.test_request_context('/'): + user = self.app.security.datastore.find_user(email='matt@lp.com') + self.app.security.datastore.delete_user(user) + + r = self._get('/') + self.assertNotIn('Hello matt@lp.com', r.data) + + def test_remember_token(self): + r = self.authenticate(follow_redirects=False) + self.client.cookie_jar.clear_session_cookies() + r = self._get('/profile') + self.assertIn('profile', r.data) + class ConfiguredSecurityTests(SecurityTest): From 58e28566125d0fb8f1e65e87a2d93302f993e143 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 14 Aug 2012 13:52:30 -0400 Subject: [PATCH 120/234] No need to store remember_token in DB --- example/app.py | 4 ++-- flask_security/confirmable.py | 6 ------ flask_security/core.py | 29 ++++++++++++++--------------- flask_security/datastore.py | 21 ++++++++++++++++++--- flask_security/utils.py | 9 --------- 5 files changed, 34 insertions(+), 35 deletions(-) diff --git a/example/app.py b/example/app.py index e739c6de..dfcdbcf4 100644 --- a/example/app.py +++ b/example/app.py @@ -123,6 +123,7 @@ def create_sqlalchemy_app(auth_config=None, register_blueprint=True): app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://root@localhost/flask_security_test' db = SQLAlchemy(app) + app.db = db roles_users = db.Table('roles_users', db.Column('user_id', db.Integer(), db.ForeignKey('user.id')), @@ -137,7 +138,6 @@ class User(db.Model, UserMixin): id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(255), unique=True) password = db.Column(db.String(255)) - remember_token = db.Column(db.String(255)) last_login_at = db.Column(db.DateTime()) current_login_at = db.Column(db.DateTime()) last_login_ip = db.Column(db.String(100)) @@ -173,6 +173,7 @@ def create_mongoengine_app(auth_config=None): app.config['MONGODB_PORT'] = 27017 db = MongoEngine(app) + app.db = db class Role(db.Document, RoleMixin): name = db.StringField(required=True, unique=True, max_length=80) @@ -181,7 +182,6 @@ class Role(db.Document, RoleMixin): class User(db.Document, UserMixin): email = db.StringField(unique=True, max_length=255) password = db.StringField(required=True, max_length=255) - remember_token = db.StringField(max_length=255) last_login_at = db.DateTimeField() current_login_at = db.DateTimeField() last_login_ip = db.StringField(max_length=100) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index bea58424..839a751c 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -73,9 +73,6 @@ def confirm_by_token(token): data = serializer.loads(token, max_age=max_age) user = _datastore.find_user(id=data[0]) - if md5(user.email) != data[1]: - raise UserNotFoundError() - if user.confirmed_at: raise ConfirmationError(get_message('ALREADY_CONFIRMED')) @@ -94,9 +91,6 @@ def confirm_by_token(token): except BadSignature: raise ConfirmationError(get_message('INVALID_CONFIRMATION_TOKEN')) - except UserNotFoundError: - raise ConfirmationError(get_message('INVALID_CONFIRMATION_TOKEN')) - def reset_confirmation_token(user): """Resets the specified user's confirmation token and sends the user diff --git a/flask_security/core.py b/flask_security/core.py index fbef5fce..4c75ba77 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -21,7 +21,7 @@ from . import views, exceptions from .confirmable import requires_confirmation -from .utils import config_value as cv, get_config, verify_password +from .utils import config_value as cv, get_config, verify_password, md5 # Convenient references _security = LocalProxy(lambda: current_app.extensions['security']) @@ -62,6 +62,7 @@ 'CONFIRM_SALT': 'confirm-salt', 'RESET_SALT': 'reset-salt', 'AUTH_SALT': 'auth-salt', + 'REMEMBER_SALT': 'remember-salt', 'DEFAULT_HTTP_AUTH_REALM': 'Login Required' } @@ -81,17 +82,16 @@ def _user_loader(user_id): try: - return _security.datastore.with_id(user_id) - except Exception, e: - current_app.logger.error('Error getting user: %s' % e) + return _security.datastore.find_user(id=user_id) + except: return None def _token_loader(token): try: - return _security.datastore.find_user(remember_token=token) - except Exception, e: - current_app.logger.error('Error getting user: %s' % e) + data = _security.remember_token_serializer.loads(token) + return _security.datastore.find_user(email=md5(data[0])) + except: return None @@ -137,6 +137,10 @@ def _get_serializer(app, salt): return URLSafeTimedSerializer(secret_key=secret_key, salt=salt) +def _get_remember_token_serializer(app): + return _get_serializer(app, app.config['SECURITY_REMEMBER_SALT']) + + def _get_reset_serializer(app): return _get_serializer(app, app.config['SECURITY_RESET_SALT']) @@ -157,9 +161,6 @@ def __eq__(self, other): def __ne__(self, other): return self.name != other and self.name != getattr(other, 'name', None) - def __str__(self): - return '' % self.name - class UserMixin(BaseUserMixin): """Mixin for `User` model definitions""" @@ -170,7 +171,8 @@ def is_active(self): def get_auth_token(self): """Returns the user's authentication token.""" - self.remember_token + data = [md5(self.email), self.password] + return _security.remember_token_serializer.dumps(data) def has_role(self, role): """Returns `True` if the user identifies with the specified role. @@ -178,10 +180,6 @@ def has_role(self, role): :param role: A role name or `Role` instance""" return role in self.roles - def __str__(self): - ctx = (str(self.id), self.email) - return '' % ctx - class AnonymousUser(AnonymousUserBase): """AnonymousUser definition""" @@ -262,6 +260,7 @@ def _get_state(self, app, datastore): ('login_manager', _get_login_manager(app)), ('principal', _get_principal(app)), ('pwd_context', _get_pwd_context(app)), + ('remember_token_serializer', _get_remember_token_serializer(app)), ('token_auth_serializer', _get_token_auth_serializer(app))]: kwargs[key] = value diff --git a/flask_security/datastore.py b/flask_security/datastore.py index e709cb09..14699776 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -37,6 +37,10 @@ def _save_model(self, model, **kwargs): raise NotImplementedError( "User datastore does not implement _save_model method") + def _delete_model(self, model): + raise NotImplementedError( + "User datastore does not implement _delete_model method") + def _do_with_id(self, id): raise NotImplementedError( "User datastore does not implement _do_with_id method") @@ -118,9 +122,6 @@ def _prepare_create_user_args(self, kwargs): use_hmac=_security.password_hmac) kwargs['password'] = pwd_hash - kwargs['remember_token'] = utils.get_remember_token(kwargs['email'], - kwargs['password']) - return kwargs def with_id(self, id): @@ -170,6 +171,13 @@ def create_user(self, **kwargs): user = self.user_model(**self._prepare_create_user_args(kwargs)) return self._save_model(user) + def delete_user(self, user): + """Delete the specified user + + :param user: The user to delete_user + """ + self._delete_model(user) + def add_role_to_user(self, user, role): """Adds a role to a user if the user does not have it already. Returns the modified user. @@ -248,6 +256,10 @@ def _save_model(self, model): self.db.session.commit() return model + def _delete_model(self, model): + self.db.session.delete(model) + self.db.session.commit() + def _do_with_id(self, id): return self.user_model.query.get(id) @@ -292,6 +304,9 @@ def _save_model(self, model): model.save() return model + def _delete_model(self, model): + model.delete() + def _do_with_id(self, id): try: return self.user_model.objects.get(id=id) diff --git a/flask_security/utils.py b/flask_security/utils.py index ef840065..0706f4f2 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -44,9 +44,6 @@ def login_user(user, remember=True): if user.authentication_token is None: user.authentication_token = generate_authentication_token(user) - if remember: - user.remember_token = get_remember_token(user.email, user.password) - if _security.trackable: old_current, new_current = user.current_login_at, datetime.utcnow() user.last_login_at = old_current or new_current @@ -110,12 +107,6 @@ def generate_token(): return base64.urlsafe_b64encode(os.urandom(30)) -def get_remember_token(email, password): - assert email is not None - assert password is not None - return make_secure_token(email, password) - - def do_flash(message, category=None): """Flash a message depending on if the `FLASH_MESSAGES` configuration value is set. From 404e797cd905a57e8cb0afff2a553edbd0bc706a Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 14 Aug 2012 14:18:20 -0400 Subject: [PATCH 121/234] Add more test coverage --- flask_security/core.py | 16 +--------------- flask_security/views.py | 4 ++-- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 4c75ba77..349688ce 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -237,9 +237,6 @@ def init_app(self, app, datastore=None, register_blueprint=True): state = self._get_state(app, datastore or self.datastore) - if not hasattr(app, 'extensions'): - app.extensions = {} - app.extensions['security'] = state return state @@ -314,12 +311,8 @@ def do_authenticate(self, username_or_email, password): try: user = self._get_user(username_or_email) - except AttributeError, e: - self.auth_error("Could not find user datastore: %s" % e) - except exceptions.UserNotFoundError, e: + except exceptions.UserNotFoundError: raise exceptions.BadCredentialsError("Specified user does not exist") - except Exception, e: - self.auth_error('Unexpected authentication error: %s' % e) if requires_confirmation(user): raise exceptions.BadCredentialsError('Account requires confirmation') @@ -332,10 +325,3 @@ def do_authenticate(self, username_or_email, password): # bad match raise exceptions.BadCredentialsError("Password does not match") - - def auth_error(self, msg): - """Sends an error log message and raises an authentication error. - - :param msg: An authentication error message""" - current_app.logger.error(msg) - raise exceptions.AuthenticationError(msg) diff --git a/flask_security/views.py b/flask_security/views.py index a0eb4150..548f2bdd 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -132,10 +132,10 @@ def register_user(): def send_confirmation(): - form = ResendConfirmationForm() + form = ResendConfirmationForm(csrf_enabled=not app.testing) if form.validate_on_submit(): - user = _datastore.find_user(email=form.email.data) + user = _datastore.find_user(**form.to_dict()) reset_confirmation_token(user) From e9adf91a278c0f3b2ce99bb6da9065f946d8df60 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 14 Aug 2012 14:27:58 -0400 Subject: [PATCH 122/234] More and more test coverage --- flask_security/decorators.py | 10 +++------- tests/functional_tests.py | 36 ++++++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 400cb0e1..82ac8e5e 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -14,6 +14,7 @@ from flask import current_app, Response, request, redirect from flask.ext.login import login_required, login_url, current_user from flask.ext.principal import RoleNeed, Permission, Identity, identity_changed +from itsdangerous import BadSignature from werkzeug.local import LocalProxy from . import utils @@ -57,13 +58,8 @@ def _check_token(): try: data = serializer.loads(token) - user = _security.datastore.find_user(id=data[0], - authentication_token=token) - - if data[1] != utils.md5(user.email): - raise Exception() - - except Exception: + _security.datastore.find_user(id=data[0], authentication_token=token) + except BadSignature: return False return True diff --git a/tests/functional_tests.py b/tests/functional_tests.py index cba617ae..a13ba433 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -27,6 +27,11 @@ def get_cookies(rv): class DefaultSecurityTests(SecurityTest): + def test_instance(self): + self.assertIsNotNone(self.app) + self.assertIsNotNone(self.app.security) + self.assertIsNotNone(self.app.security.pwd_context) + def test_login_view(self): r = self._get('/login') self.assertIn('Login Page', r.data) @@ -145,7 +150,15 @@ def test_http_auth(self): }) self.assertIn('HTTP Authentication', r.data) - def test_invalid_http_auth(self): + def test_invalid_http_auth_invalid_username(self): + r = self._get('/http', headers={ + 'Authorization': 'Basic ' + base64.b64encode("bogus:bogus") + }) + self.assertIn('

    Unauthorized

    ', r.data) + self.assertIn('WWW-Authenticate', r.headers) + self.assertEquals('Basic realm="Login Required"', r.headers['WWW-Authenticate']) + + def test_invalid_http_auth_bad_password(self): r = self._get('/http', headers={ 'Authorization': 'Basic ' + base64.b64encode("joe@lp.com:bogus") }) @@ -249,6 +262,12 @@ class ConfirmableTests(SecurityTest): 'SECURITY_REGISTERABLE': True } + def test_login_before_confirmation(self): + e = 'dude@lp.com' + self.register(e) + r = self.authenticate(email=e) + self.assertIn('Account requires confirmation', r.data) + def test_register_sends_confirmation_email(self): e = 'dude@lp.com' with self.app.mail.record_messages() as outbox: @@ -282,6 +301,12 @@ def test_invalid_token_when_confirming_email(self): r = self.client.get('/confirm/bogus', follow_redirects=True) self.assertIn('Invalid confirmation token', r.data) + def test_resend_confirmation(self): + e = 'dude@lp.com' + self.register(e) + r = self._post('/confirm', data={'email': e}) + self.assertIn('A new confirmation code has been sent to dude@lp.com', r.data) + class ExpiredConfirmationTest(SecurityTest): AUTH_CONFIG = { @@ -352,7 +377,7 @@ def test_reset_password_with_valid_token(self): follow_redirects=True) t = requests[0]['token'] - r = self.client.post('/reset/' + t, data={ + r = self._post('/reset/' + t, data={ 'password': 'newpassword', 'password_confirm': 'newpassword' }, follow_redirects=True) @@ -360,6 +385,13 @@ def test_reset_password_with_valid_token(self): r = self.authenticate('joe@lp.com', 'newpassword') self.assertIn('Hello joe@lp.com', r.data) + def test_reset_password_with_invalid_token(self): + r = self._post('/reset/bogus', data={ + 'password': 'newpassword', + 'password_confirm': 'newpassword' + }, follow_redirects=True) + self.assertIn('Invalid reset password token', r.data) + def test_reset_password_twice_flashes_invalid_token_msg(self): with capture_reset_password_requests() as requests: self.client.post('/reset', data=dict(email='joe@lp.com')) From 05bd2a5aae991a574ad58a128d308eac06c1e251 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 14 Aug 2012 15:57:07 -0400 Subject: [PATCH 123/234] Full test coverage! --- example/app.py | 42 +++++++++++++++++++++++-- example/templates/register.html | 1 + flask_security/datastore.py | 55 +++++--------------------------- flask_security/exceptions.py | 6 ---- flask_security/utils.py | 19 +++-------- flask_security/views.py | 4 --- tests/__init__.py | 5 +-- tests/functional_tests.py | 56 +++++++++++++++++++++++++++++++++ tests/unit_tests.py | 11 +++++++ 9 files changed, 122 insertions(+), 77 deletions(-) diff --git a/example/app.py b/example/app.py index dfcdbcf4..fb159382 100644 --- a/example/app.py +++ b/example/app.py @@ -18,6 +18,7 @@ MongoEngineUserDatastore from flask.ext.security.decorators import http_auth_required, \ auth_token_required +from flask.ext.security.exceptions import RoleNotFoundError def create_roles(): @@ -45,12 +46,12 @@ def create_app(auth_config): app.debug = True app.config['SECRET_KEY'] = 'secret' + app.mail = Mail(app) + if auth_config: for key, value in auth_config.items(): app.config[key] = value - app.mail = Mail(app) - @app.route('/') def index(): return render_template('index.html', content='Home Page') @@ -115,6 +116,43 @@ def admin_or_editor(): def unauthorized(): return render_template('unauthorized.html') + @app.route('/coverage/add_role_to_user') + def add_role_to_user(): + ds = app.security.datastore + u = ds.find_user(email='joe@lp.com') + r = ds.find_role('admin') + ds.add_role_to_user(u, r) + return 'success' + + @app.route('/coverage/remove_role_from_user') + def remove_role_from_user(): + ds = app.security.datastore + u = ds.find_user(email='matt@lp.com') + ds.remove_role_from_user(u, 'admin') + return 'success' + + @app.route('/coverage/deactivate_user') + def deactivate_user(): + ds = app.security.datastore + u = ds.find_user(email='matt@lp.com') + ds.deactivate_user(u) + return 'success' + + @app.route('/coverage/activate_user') + def activate_user(): + ds = app.security.datastore + u = ds.find_user(email='tiya@lp.com') + ds.activate_user(u) + return 'success' + + @app.route('/coverage/invalid_role') + def invalid_role(): + ds = app.security.datastore + try: + ds.find_role('bogus') + except RoleNotFoundError: + return 'success' + return app diff --git a/example/templates/register.html b/example/templates/register.html index debcaffa..440fdd22 100644 --- a/example/templates/register.html +++ b/example/templates/register.html @@ -1,5 +1,6 @@ {% include "_messages.html" %} {% include "_nav.html" %} +

    Register

    {{ register_user_form.hidden_tag() }} {{ register_user_form.email.label }} {{ register_user_form.email }}
    diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 14699776..d80f1146 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -20,7 +20,7 @@ class UserDatastore(object): """Abstracted user datastore. Always extend this class and implement the - :attr:`_save_model`, :attr:`_do_with_id`, :attr:`_do_find_user`, and + :attr:`_save_model`, :attr:`_delete_model`, :attr:`_do_find_user`, and :attr:`_do_find_role` methods. :param db: An instance of a configured databse manager from a Flask @@ -41,10 +41,6 @@ def _delete_model(self, model): raise NotImplementedError( "User datastore does not implement _delete_model method") - def _do_with_id(self, id): - raise NotImplementedError( - "User datastore does not implement _do_with_id method") - def _do_find_user(self, **kwargs): raise NotImplementedError( "User datastore does not implement _do_find_user method") @@ -65,11 +61,9 @@ def _do_remove_role(self, user, role): user.roles.remove(role) return user - def _do_toggle_active(self, user, active=None): + def _do_toggle_active(self, user, active): user = self.find_user(email=user.email) - if active is None: - user.active = not user.active - elif active != user.active: + if active != user.active: user.active = active return user @@ -80,30 +74,13 @@ def _do_active_user(self, user): return self._do_toggle_active(user, True) def _prepare_role_modify_args(self, user, role): - if isinstance(role, self.role_model): - role = role.name - + role = role.name if isinstance(role, self.role_model) else role return self.find_user(email=user.email), self.find_role(role) - def _prepare_create_role_args(self, kwargs): - if kwargs['name'] is None: - raise exceptions.RoleCreationError("Missing name argument") - - return kwargs - - def _prepare_create_user_args(self, kwargs): - email = kwargs.get('email', None) - password = kwargs.get('password', None) - + def _prepare_create_user_args(self, **kwargs): kwargs.setdefault('active', True) kwargs.setdefault('roles', _security.default_roles) - if email is None: - raise exceptions.UserCreationError('Missing email argument') - - if password is None: - raise exceptions.UserCreationError('Missing password argument') - roles = kwargs.get('roles', []) for i, role in enumerate(roles): @@ -124,15 +101,6 @@ def _prepare_create_user_args(self, kwargs): return kwargs - def with_id(self, id): - """Returns a user with the specified ID. - - :param id: User ID""" - user = self._do_with_id(id) - if user: - return user - raise exceptions.UserIdNotFoundError() - def find_user(self, **kwargs): """Returns a user based on the specified identifier. @@ -158,7 +126,7 @@ def create_role(self, **kwargs): :param name: Role name """ - role = self.role_model(**self._prepare_create_role_args(kwargs)) + role = self.role_model(**kwargs) return self._save_model(role) def create_user(self, **kwargs): @@ -168,7 +136,7 @@ def create_user(self, **kwargs): :param password: Unencrypted password :param active: The optional active state """ - user = self.user_model(**self._prepare_create_user_args(kwargs)) + user = self.user_model(**self._prepare_create_user_args(**kwargs)) return self._save_model(user) def delete_user(self, user): @@ -260,9 +228,6 @@ def _delete_model(self, model): self.db.session.delete(model) self.db.session.commit() - def _do_with_id(self, id): - return self.user_model.query.get(id) - def _do_find_user(self, **kwargs): return self.user_model.query.filter_by(**kwargs).first() @@ -307,12 +272,6 @@ def _save_model(self, model): def _delete_model(self, model): model.delete() - def _do_with_id(self, id): - try: - return self.user_model.objects.get(id=id) - except: - return None - def _do_find_user(self, **kwargs): return self.user_model.objects(**kwargs).first() diff --git a/flask_security/exceptions.py b/flask_security/exceptions.py index 6802c2cf..4522a618 100644 --- a/flask_security/exceptions.py +++ b/flask_security/exceptions.py @@ -40,12 +40,6 @@ class RoleNotFoundError(SecurityError): """ -class UserIdNotFoundError(SecurityError): - """Raised by a user datastore when there is an attempt to find a user by - ID and the user is not found. - """ - - class UserDatastoreError(SecurityError): """Raised when a user datastore experiences an unexpected error """ diff --git a/flask_security/utils.py b/flask_security/utils.py index 0706f4f2..9644bedb 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -49,11 +49,12 @@ def login_user(user, remember=True): user.last_login_at = old_current or new_current user.current_login_at = new_current - old_current, new_current = user.current_login_ip, request.remote_addr + remote_addr = request.remote_addr or 'untrackable' + old_current, new_current = user.current_login_ip, remote_addr user.last_login_ip = old_current or new_current user.current_login_ip = new_current - user.login_count = user.login_count + 1 if user.login_count else 0 + user.login_count = user.login_count + 1 if user.login_count else 1 _datastore._save_model(user) @@ -102,11 +103,6 @@ def md5(data): return hashlib.md5(data).hexdigest() -def generate_token(): - """Generate an arbitrary URL safe token.""" - return base64.urlsafe_b64encode(os.urandom(30)) - - def do_flash(message, category=None): """Flash a message depending on if the `FLASH_MESSAGES` configuration value is set. @@ -216,16 +212,9 @@ def send_mail(subject, recipient, template, context=None): :param template: The name of the email template :param context: The context to render the template with """ - mail = current_app.extensions.get('mail', None) - current_app.logger.debug('%s' % current_app.extensions) - - if mail is None: - raise RuntimeError('You need to install and configure the ' - 'Flask-Mail extension in order to send ' - 'emails with Flask-Security') - from flask.ext.mail import Message + mail = current_app.extensions.get('mail') context = context or {} msg = Message(subject, diff --git a/flask_security/views.py b/flask_security/views.py index 548f2bdd..5605afb5 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -145,10 +145,6 @@ def send_confirmation(): do_flash(msg, cat) - else: - for key, value in form.errors.items(): - do_flash(value[0], 'error') - return render_template('security/confirmations/new.html', reset_confirmation_form=form) diff --git a/tests/__init__.py b/tests/__init__.py index 13b34c96..9440844b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -23,10 +23,11 @@ def _get(self, route, content_type=None, follow_redirects=None, headers=None): content_type=content_type or 'text/html', headers=headers) - def _post(self, route, data=None, content_type=None, follow_redirects=True): + def _post(self, route, data=None, content_type=None, follow_redirects=True, headers=None): return self.client.post(route, data=data, follow_redirects=follow_redirects, - content_type=content_type or 'application/x-www-form-urlencoded') + content_type=content_type or 'application/x-www-form-urlencoded', + headers=headers) def register(self, email, password='password'): data = dict(email=email, password=password, password_confirm=password) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index a13ba433..7ae1d6a5 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -36,6 +36,8 @@ def test_login_view(self): r = self._get('/login') self.assertIn('Login Page', r.data) + + def test_authenticate(self): r = self.authenticate() self.assertIn('Hello matt@lp.com', r.data) @@ -221,6 +223,10 @@ def test_logout(self): r = self.logout(endpoint="/custom_logout") self.assertIn('Post Logout', r.data) + def test_register_view(self): + r = self._get('/register') + self.assertIn('

    Register

    ', r.data) + def test_register(self): data = dict(email='dude@lp.com', password='password', @@ -432,7 +438,57 @@ def test_reset_password_with_expired_token(self): self.assertIn('You did not reset your password within', r.data) +class TrackableTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_TRACKABLE': True + } + + def test_did_track(self): + e = 'matt@lp.com' + self.authenticate(email=e) + self.logout() + self.authenticate(email=e) + + with self.app.test_request_context('/profile'): + user = self.app.security.datastore.find_user(email=e) + self.assertIsNotNone(user.last_login_at) + self.assertIsNotNone(user.current_login_at) + self.assertEquals('untrackable', user.last_login_ip) + self.assertEquals('untrackable', user.current_login_ip) + self.assertEquals(2, user.login_count) + + class MongoEngineSecurityTests(DefaultSecurityTests): def _create_app(self, auth_config): return app.create_mongoengine_app(auth_config) + + +class DefaultDatastoreTests(SecurityTest): + + def test_add_role_to_user(self): + r = self._get('/coverage/add_role_to_user') + self.assertIn('success', r.data) + + def test_remove_role_from_user(self): + r = self._get('/coverage/remove_role_from_user') + self.assertIn('success', r.data) + + def test_activate_user(self): + r = self._get('/coverage/activate_user') + self.assertIn('success', r.data) + + def test_deactivate_user(self): + r = self._get('/coverage/deactivate_user') + self.assertIn('success', r.data) + + def test_invalid_role(self): + r = self._get('/coverage/invalid_role') + self.assertIn('success', r.data) + + +class MongoEngineDatastoreTests(DefaultDatastoreTests): + + def _create_app(self, auth_config): + return app.create_mongoengine_app(auth_config) diff --git a/tests/unit_tests.py b/tests/unit_tests.py index 9a3158e9..ad0b37d4 100644 --- a/tests/unit_tests.py +++ b/tests/unit_tests.py @@ -2,6 +2,7 @@ import unittest from flask_security import RoleMixin, UserMixin, AnonymousUser +from flask_security.datastore import UserDatastore class Role(RoleMixin): @@ -41,3 +42,13 @@ def test_anonymous_user_has_no_roles(self): au = AnonymousUser() self.assertEqual(0, len(au.roles)) self.assertFalse(au.has_role('admin')) + + +class UserDatastoreTests(unittest.TestCase): + + def test_unimplemented(self): + ds = UserDatastore(None, None, None) + self.assertRaises(NotImplementedError, ds._save_model, None) + self.assertRaises(NotImplementedError, ds._delete_model, None) + self.assertRaises(NotImplementedError, ds._do_find_user) + self.assertRaises(NotImplementedError, ds._do_find_role) From 6f7d5378e74522510acde0eeba6cd5524d9610f3 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 14 Aug 2012 16:03:38 -0400 Subject: [PATCH 124/234] Remove reference to `remember_token` in docs --- docs/index.rst | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 6c7a9f10..b11a614a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -109,7 +109,6 @@ using SQLAlchemy.:: id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(255), unique=True) password = db.Column(db.String(255)) - remember_token = db.Column(db.String(255)) active = db.Column(db.Boolean()) authentication_token = db.Column(db.String(255)) roles = db.relationship('Role', secondary=roles_users, @@ -159,7 +158,6 @@ and `Role` model should include the following fields: * id * email * password -* remember_token * active * authentication_token From 68b0410d1b09ee5c2c679dede590cf5811167121 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 14 Aug 2012 16:21:31 -0400 Subject: [PATCH 125/234] No need to keep authentication token in DB --- example/app.py | 2 -- flask_security/core.py | 10 ++++------ flask_security/decorators.py | 8 ++++---- flask_security/utils.py | 12 ------------ flask_security/views.py | 2 +- 5 files changed, 9 insertions(+), 25 deletions(-) diff --git a/example/app.py b/example/app.py index fb159382..32d0a939 100644 --- a/example/app.py +++ b/example/app.py @@ -183,7 +183,6 @@ class User(db.Model, UserMixin): login_count = db.Column(db.Integer) active = db.Column(db.Boolean()) confirmed_at = db.Column(db.DateTime()) - authentication_token = db.Column(db.String(255)) roles = db.relationship('Role', secondary=roles_users, backref=db.backref('users', lazy='dynamic')) @@ -227,7 +226,6 @@ class User(db.Document, UserMixin): login_count = db.IntField() active = db.BooleanField(default=True) confirmed_at = db.DateTimeField() - authentication_token = db.StringField(max_length=255) roles = db.ListField(db.ReferenceField(Role), default=[]) app.security = Security(app, MongoEngineUserDatastore(db, User, Role)) diff --git a/flask_security/core.py b/flask_security/core.py index 349688ce..5fe3af39 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -88,11 +88,9 @@ def _user_loader(user_id): def _token_loader(token): - try: - data = _security.remember_token_serializer.loads(token) - return _security.datastore.find_user(email=md5(data[0])) - except: - return None + data = _security.remember_token_serializer.loads(token) + user = _security.datastore.find_user(id=data[0]) + return user if md5(user.password) == data[1] else None def _identity_loader(): @@ -171,7 +169,7 @@ def is_active(self): def get_auth_token(self): """Returns the user's authentication token.""" - data = [md5(self.email), self.password] + data = [str(self.id), md5(self.password)] return _security.remember_token_serializer.dumps(data) def has_role(self, role): diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 82ac8e5e..5b766cae 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -54,15 +54,15 @@ def _check_token(): header_token = request.headers.get(header_key, None) token = request.args.get(args_key, header_token) - serializer = _security.token_auth_serializer + serializer = _security.remember_token_serializer try: data = serializer.loads(token) - _security.datastore.find_user(id=data[0], authentication_token=token) - except BadSignature: + user = _security.datastore.find_user(id=data[0]) + except: return False - return True + return True if utils.md5(user.password) == data[1] else False def _check_http_auth(): diff --git a/flask_security/utils.py b/flask_security/utils.py index 9644bedb..294d8931 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -41,9 +41,6 @@ def login_user(user, remember=True): if not _login_user(user, remember): return False - if user.authentication_token is None: - user.authentication_token = generate_authentication_token(user) - if _security.trackable: old_current, new_current = user.current_login_at, datetime.utcnow() user.last_login_at = old_current or new_current @@ -90,15 +87,6 @@ def encrypt_password(password, salt=None, use_hmac=False): return _pwd_context.encrypt(hmac_value) -def generate_authentication_token(user): - """Generates a unique authentication token for the specified user. - - :param user: The user to work with - """ - data = [str(user.id), md5(user.email)] - return _security.token_auth_serializer.dumps(data) - - def md5(data): return hashlib.md5(data).hexdigest() diff --git a/flask_security/views.py b/flask_security/views.py index 5605afb5..751a7d68 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -44,7 +44,7 @@ def _json_auth_ok(user): "response": { "user": { "id": str(user.id), - "authentication_token": user.authentication_token + "authentication_token": user.get_auth_token() } } }) From 4b31de057c5f13d5378da2817fdf758c5bab6ed3 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 14 Aug 2012 16:21:50 -0400 Subject: [PATCH 126/234] Remove reference to --- docs/index.rst | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index b11a614a..6ef0253c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -110,7 +110,6 @@ using SQLAlchemy.:: email = db.Column(db.String(255), unique=True) password = db.Column(db.String(255)) active = db.Column(db.Boolean()) - authentication_token = db.Column(db.String(255)) roles = db.relationship('Role', secondary=roles_users, backref=db.backref('users', lazy='dynamic')) @@ -159,7 +158,6 @@ and `Role` model should include the following fields: * email * password * active -* authentication_token **Role** From 4daefcf1f7479fdfa03bd997e11f6e22e38bf30f Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 14 Aug 2012 16:21:58 -0400 Subject: [PATCH 127/234] Change name --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6265e8be..eb80240c 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ version='1.3.0-dev', url='https://github.com/mattupstate/flask-security', license='MIT', - author='Matthew Wright', + author='Matt Wright', author_email='matt@nobien.net', description='Simple security for Flask apps', long_description=__doc__, From f3dff9e4960d502c672b3d6243a335f816cb77dd Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 14 Aug 2012 16:37:56 -0400 Subject: [PATCH 128/234] Clean up --- flask_security/confirmable.py | 4 +--- flask_security/recoverable.py | 4 ---- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 839a751c..43c952cd 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -15,7 +15,7 @@ from flask import current_app as app, request, url_for from werkzeug.local import LocalProxy -from .exceptions import UserNotFoundError, ConfirmationError +from .exceptions import ConfirmationError from .utils import send_mail, get_max_age, md5, get_message from .signals import user_confirmed, confirm_instructions_sent @@ -42,8 +42,6 @@ def send_confirmation_instructions(user, token): confirm_instructions_sent.send(user, app=app._get_current_object()) - return True - def generate_confirmation_token(user): """Generates a unique confirmation token for the specified user. diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 6b4e0a38..f394affd 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -43,8 +43,6 @@ def send_reset_password_instructions(user, reset_token): reset_instructions_sent.send(dict(user=user, token=reset_token), app=app._get_current_object()) - return True - def send_password_reset_notice(user): """Sends the password reset notice email for the specified user. @@ -121,5 +119,3 @@ def reset_password_reset_token(user): password_reset_requested.send(dict(user=user, token=token), app=app._get_current_object()) - - return token From 05242715731fb851e5a65b26b6de22a2029195b9 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 14 Aug 2012 16:38:31 -0400 Subject: [PATCH 129/234] Clean up --- flask_security/recoverable.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index f394affd..ea20aa9e 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -119,3 +119,5 @@ def reset_password_reset_token(user): password_reset_requested.send(dict(user=user, token=token), app=app._get_current_object()) + + return token From ce6c5dcf313c957090746be72504e7990b143a87 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 14 Aug 2012 16:59:05 -0400 Subject: [PATCH 130/234] Clean up --- flask_security/confirmable.py | 2 +- flask_security/core.py | 10 +++++----- flask_security/views.py | 10 +++++----- tests/functional_tests.py | 15 +++++++++------ 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 43c952cd..3ae6d1cd 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -31,7 +31,7 @@ def send_confirmation_instructions(user, token): :param user: The user to send the instructions to """ - url = url_for('flask_security.confirm_account', token=token) + url = url_for('flask_security.confirm_email', token=token) confirmation_link = request.url_root[:-1] + url diff --git a/flask_security/core.py b/flask_security/core.py index 5fe3af39..22b8288b 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -69,14 +69,14 @@ #: Default Flask-Security flash messages _default_flash_messages = { 'UNAUTHORIZED': ('You do not have permission to view this resource.', 'error'), - 'ACCOUNT_CONFIRMED': ('Your account has been confirmed. You may now log in.', 'success'), - 'ALREADY_CONFIRMED': ('Your account has already been confirmed', 'info'), + 'EMAIL_CONFIRMED': ('Your email has been confirmed. You may now log in.', 'success'), + 'ALREADY_CONFIRMED': ('Your email has already been confirmed', 'info'), 'INVALID_CONFIRMATION_TOKEN': ('Invalid confirmation token', 'error'), 'PASSWORD_RESET_REQUEST': ('Instructions to reset your password have been sent to %(email)s.', 'info'), 'PASSWORD_RESET_EXPIRED': ('You did not reset your password within %(within)s. New instructions have been sent to %(email)s.', 'error'), 'INVALID_RESET_PASSWORD_TOKEN': ('Invalid reset password token', 'error'), 'CONFIRMATION_REQUEST': ('A new confirmation code has been sent to %(email)s.', 'info'), - 'CONFIRMATION_EXPIRED': ('You did not confirm your account within %(within)s. New instructions to confirm your account have been sent to %(email)s.', 'error') + 'CONFIRMATION_EXPIRED': ('You did not confirm your email within %(within)s. New instructions to confirm your email have been sent to %(email)s.', 'error') } @@ -310,10 +310,10 @@ def do_authenticate(self, username_or_email, password): try: user = self._get_user(username_or_email) except exceptions.UserNotFoundError: - raise exceptions.BadCredentialsError("Specified user does not exist") + raise exceptions.BadCredentialsError('Specified user does not exist') if requires_confirmation(user): - raise exceptions.BadCredentialsError('Account requires confirmation') + raise exceptions.BadCredentialsError('Email requires confirmation') # compare passwords if verify_password(password, user.password, diff --git a/flask_security/views.py b/flask_security/views.py index 751a7d68..7d02d199 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -149,11 +149,11 @@ def send_confirmation(): reset_confirmation_form=form) -def confirm_account(token): - """View function which handles a account confirmation request.""" +def confirm_email(token): + """View function which handles a email confirmation request.""" try: user = confirm_by_token(token) - _logger.debug('%s confirmed their account' % user) + _logger.debug('%s confirmed their email' % user) except ConfirmationError, e: msg, cat = str(e), 'error' @@ -171,7 +171,7 @@ def confirm_account(token): return redirect(get_url(_security.confirm_error_view)) - do_flash(get_message('ACCOUNT_CONFIRMED')) + do_flash(get_message('EMAIL_CONFIRMED')) return redirect(_security.post_confirm_view or _security.post_login_view) @@ -263,6 +263,6 @@ def create_blueprint(app, name, import_name, **kwargs): endpoint='send_confirmation')(send_confirmation) bp.route(config_value('CONFIRM_URL', app=app) + '/', methods=['GET', 'POST'], - endpoint='confirm_account')(confirm_account) + endpoint='confirm_email')(confirm_email) return bp diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 7ae1d6a5..2aa236d1 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -272,7 +272,7 @@ def test_login_before_confirmation(self): e = 'dude@lp.com' self.register(e) r = self.authenticate(email=e) - self.assertIn('Account requires confirmation', r.data) + self.assertIn('Email requires confirmation', r.data) def test_register_sends_confirmation_email(self): e = 'dude@lp.com' @@ -289,7 +289,9 @@ def test_confirm_email(self): token = registrations[0]['confirm_token'] r = self.client.get('/confirm/' + token, follow_redirects=True) - self.assertIn('Your account has been confirmed. You may now log in.', r.data) + + msg = self.app.config['SECURITY_MSG_EMAIL_CONFIRMED'][0] + self.assertIn(msg, r.data) def test_confirm_email_twice_flashes_already_confirmed_message(self): e = 'dude@lp.com' @@ -301,7 +303,9 @@ def test_confirm_email_twice_flashes_already_confirmed_message(self): url = '/confirm/' + token self.client.get(url, follow_redirects=True) r = self.client.get(url, follow_redirects=True) - self.assertIn('Your account has already been confirmed', r.data) + + msg = self.app.config['SECURITY_MSG_ALREADY_CONFIRMED'][0] + self.assertIn(msg, r.data) def test_invalid_token_when_confirming_email(self): r = self.client.get('/confirm/bogus', follow_redirects=True) @@ -338,9 +342,8 @@ def test_expired_confirmation_token_sends_email(self): self.assertNotIn(token, outbox[0].html) expire_text = self.AUTH_CONFIG['SECURITY_CONFIRM_EMAIL_WITHIN'] - text = 'You did not confirm your account within %s' % expire_text - - self.assertIn(text, r.data) + msg = self.app.config['SECURITY_MSG_CONFIRMATION_EXPIRED'][0] % dict(within=expire_text, email=e) + self.assertIn(msg, r.data) class LoginWithoutImmediateConfirmTests(SecurityTest): From 318cb3dc6ef4904cc451838f9cbeb55a7681eeca Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 14 Aug 2012 19:01:49 -0400 Subject: [PATCH 131/234] First commit of passwordless login --- example/app.py | 13 +++- example/templates/passwordless_login.html | 9 +++ flask_security/__init__.py | 2 +- flask_security/confirmable.py | 8 +- flask_security/core.py | 16 +++- flask_security/exceptions.py | 8 ++ flask_security/forms.py | 14 ++++ flask_security/passwordless.py | 74 +++++++++++++++++++ flask_security/signals.py | 2 + .../security/email/login_instructions.html | 5 ++ .../security/email/login_instructions.txt | 5 ++ .../security/logins/passwordless.html | 8 ++ flask_security/utils.py | 18 ++++- flask_security/views.py | 62 +++++++++++++--- tests/functional_tests.py | 68 ++++++++++++++++- 15 files changed, 291 insertions(+), 21 deletions(-) create mode 100644 example/templates/passwordless_login.html create mode 100644 flask_security/passwordless.py create mode 100644 flask_security/templates/security/email/login_instructions.html create mode 100644 flask_security/templates/security/email/login_instructions.txt create mode 100644 flask_security/templates/security/logins/passwordless.html diff --git a/example/app.py b/example/app.py index 32d0a939..0bf4b3f0 100644 --- a/example/app.py +++ b/example/app.py @@ -12,8 +12,8 @@ from flask.ext.mail import Mail from flask.ext.mongoengine import MongoEngine from flask.ext.sqlalchemy import SQLAlchemy -from flask.ext.security import Security, LoginForm, login_required, \ - roles_required, roles_accepted, UserMixin, RoleMixin +from flask.ext.security import Security, LoginForm, PasswordlessLoginForm, \ + login_required, roles_required, roles_accepted, UserMixin, RoleMixin from flask.ext.security.datastore import SQLAlchemyUserDatastore, \ MongoEngineUserDatastore from flask.ext.security.decorators import http_auth_required, \ @@ -58,7 +58,14 @@ def index(): @app.route('/login') def login(): - return render_template('login.html', content='Login Page', form=LoginForm()) + if app.config['SECURITY_PASSWORDLESS']: + form = PasswordlessLoginForm() + template = 'passwordless_login' + else: + form = LoginForm() + template = 'login' + + return render_template(template + '.html', content='Login Page', form=form) @app.route('/custom_login') def custom_login(): diff --git a/example/templates/passwordless_login.html b/example/templates/passwordless_login.html new file mode 100644 index 00000000..715179b9 --- /dev/null +++ b/example/templates/passwordless_login.html @@ -0,0 +1,9 @@ +{% include "_messages.html" %} +{% include "_nav.html" %} + + {{ form.hidden_tag() }} + {{ form.email.label }} {{ form.email }}
    + {{ form.next }} + {{ form.submit }} + +

    {{ content }}

    diff --git a/flask_security/__init__.py b/flask_security/__init__.py index ceedf93c..31858b05 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -16,7 +16,7 @@ from .decorators import auth_token_required, http_auth_required, \ login_required, roles_accepted, roles_required from .forms import ForgotPasswordForm, LoginForm, RegisterForm, \ - ResetPasswordForm + ResetPasswordForm, PasswordlessLoginForm from .signals import confirm_instructions_sent, password_reset, \ password_reset_requested, reset_instructions_sent, user_confirmed, \ user_registered diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 3ae6d1cd..9dd5f14a 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -30,6 +30,7 @@ def send_confirmation_instructions(user, token): """Sends the confirmation instructions email for the specified user. :param user: The user to send the instructions to + :param token: The confirmation token """ url = url_for('flask_security.confirm_email', token=token) @@ -83,8 +84,11 @@ def confirm_by_token(token): except SignatureExpired: sig_okay, data = serializer.loads_unsafe(token) - raise ConfirmationError('Confirmation token expired', - user=_datastore.find_user(id=data[0])) + user = _datastore.find_user(id=data[0]) + msg = get_message('CONFIRMATION_EXPIRED', + within=_security.confirm_email_within, + email=user.email)[0] + raise ConfirmationError(msg, user=user) except BadSignature: raise ConfirmationError(get_message('INVALID_CONFIRMATION_TOKEN')) diff --git a/flask_security/core.py b/flask_security/core.py index 22b8288b..95f32067 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -53,6 +53,8 @@ 'REGISTERABLE': False, 'RECOVERABLE': False, 'TRACKABLE': False, + 'PASSWORDLESS': False, + 'LOGIN_WITHIN': '1 days', 'CONFIRM_EMAIL_WITHIN': '5 days', 'RESET_PASSWORD_WITHIN': '5 days', 'LOGIN_WITHOUT_CONFIRMATION': False, @@ -62,6 +64,7 @@ 'CONFIRM_SALT': 'confirm-salt', 'RESET_SALT': 'reset-salt', 'AUTH_SALT': 'auth-salt', + 'LOGIN_SALT': 'login-salt', 'REMEMBER_SALT': 'remember-salt', 'DEFAULT_HTTP_AUTH_REALM': 'Login Required' } @@ -76,7 +79,11 @@ 'PASSWORD_RESET_EXPIRED': ('You did not reset your password within %(within)s. New instructions have been sent to %(email)s.', 'error'), 'INVALID_RESET_PASSWORD_TOKEN': ('Invalid reset password token', 'error'), 'CONFIRMATION_REQUEST': ('A new confirmation code has been sent to %(email)s.', 'info'), - 'CONFIRMATION_EXPIRED': ('You did not confirm your email within %(within)s. New instructions to confirm your email have been sent to %(email)s.', 'error') + 'CONFIRMATION_EXPIRED': ('You did not confirm your email within %(within)s. New instructions to confirm your email have been sent to %(email)s.', 'error'), + 'LOGIN_EXPIRED': ('You did not login within %(within)s. New instructions to login to your account have been sent to %(email)s.', 'error'), + 'LOGIN_EMAIL_SENT': ('Instructions to log in to your account have been sent to %(email)s', 'success'), + 'INVALID_LOGIN_TOKEN': ('Invalid login token', 'error'), + 'DISABLED_ACCOUNT': ('Account is disabled', 'error') } @@ -151,6 +158,10 @@ def _get_token_auth_serializer(app): return _get_serializer(app, app.config['SECURITY_AUTH_SALT']) +def _get_login_serializer(app): + return _get_serializer(app, app.config['SECURITY_LOGIN_SALT']) + + class RoleMixin(object): """Mixin for `Role` model definitions""" def __eq__(self, other): @@ -259,6 +270,9 @@ def _get_state(self, app, datastore): ('token_auth_serializer', _get_token_auth_serializer(app))]: kwargs[key] = value + kwargs['login_serializer'] = ( + _get_login_serializer(app) if kwargs['passwordless'] else None) + kwargs['reset_serializer'] = ( _get_reset_serializer(app) if kwargs['recoverable'] else None) diff --git a/flask_security/exceptions.py b/flask_security/exceptions.py index 4522a618..06420f46 100644 --- a/flask_security/exceptions.py +++ b/flask_security/exceptions.py @@ -63,3 +63,11 @@ class ConfirmationError(SecurityError): class ResetPasswordError(SecurityError): """Raised when a password reset error occurs """ + + +class PasswordlessLoginError(SecurityError): + """Raised when a passwordless login error occurs + """ + def __init__(self, message=None, user=None, next=None): + super(PasswordlessLoginError, self).__init__(message, user) + self.next = next diff --git a/flask_security/forms.py b/flask_security/forms.py index 9fab6f45..c779a473 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -69,6 +69,20 @@ def to_dict(self): return dict(email=self.email.data) +class PasswordlessLoginForm(Form, EmailFormMixin): + """The passwordless login form""" + + next = HiddenField() + submit = SubmitField("Send Login Link") + + def __init__(self, *args, **kwargs): + super(PasswordlessLoginForm, self).__init__(*args, **kwargs) + self.next.data = request.args.get('next', None) + + def to_dict(self): + return dict(email=self.email.data) + + class LoginForm(Form, EmailFormMixin, PasswordFormMixin): """The default login form""" diff --git a/flask_security/passwordless.py b/flask_security/passwordless.py new file mode 100644 index 00000000..6820e4c5 --- /dev/null +++ b/flask_security/passwordless.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.passwordless + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security passwordless module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + +from flask import url_for, request, current_app as app +from itsdangerous import SignatureExpired, BadSignature +from werkzeug.local import LocalProxy + +from .exceptions import PasswordlessLoginError +from .signals import login_instructions_sent +from .utils import send_mail, md5, get_max_age, login_user, get_message + + +# Convenient references +_security = LocalProxy(lambda: app.extensions['security']) + +_datastore = LocalProxy(lambda: _security.datastore) + + +def send_login_instructions(user, next): + """Sends the login instructions email for the specified user. + + :param user: The user to send the instructions to + :param token: The login token + """ + token = generate_login_token(user, next) + + url = url_for('flask_security.token_login', token=token) + + login_link = request.url_root[:-1] + url + + ctx = dict(user=user, login_link=login_link) + + send_mail('Login Instructions', user.email, + 'login_instructions', ctx) + + login_instructions_sent.send(dict(user=user, login_token=token), + app=app._get_current_object()) + + +def generate_login_token(user, next): + data = [user.id, md5(user.password), next] + return _security.login_serializer.dumps(data) + + +def login_by_token(token): + serializer = _security.login_serializer + max_age = get_max_age('LOGIN') + + try: + data = serializer.loads(token, max_age=max_age) + user = _datastore.find_user(id=data[0]) + + login_user(user, True) + + return user, data[2] + + except SignatureExpired: + sig_okay, data = serializer.loads_unsafe(token) + user = _datastore.find_user(id=data[0]) + msg = get_message('LOGIN_EXPIRED', + within=_security.login_within, + email=user.email)[0] + raise PasswordlessLoginError(msg, user=user, next=data[2]) + + except BadSignature: + raise PasswordlessLoginError(get_message('INVALID_LOGIN_TOKEN')[0]) diff --git a/flask_security/signals.py b/flask_security/signals.py index 7b14aafc..dcf30916 100644 --- a/flask_security/signals.py +++ b/flask_security/signals.py @@ -20,6 +20,8 @@ confirm_instructions_sent = signals.signal("confirm-instructions-sent") +login_instructions_sent = signals.signal("login-instructions-sent") + password_reset = signals.signal("password-reset") password_reset_requested = signals.signal("password-reset-requested") diff --git a/flask_security/templates/security/email/login_instructions.html b/flask_security/templates/security/email/login_instructions.html new file mode 100644 index 00000000..45a7cb57 --- /dev/null +++ b/flask_security/templates/security/email/login_instructions.html @@ -0,0 +1,5 @@ +

    Welcome {{ user.email }}!

    + +

    You can log into your through the link below:

    + +

    Login now

    \ No newline at end of file diff --git a/flask_security/templates/security/email/login_instructions.txt b/flask_security/templates/security/email/login_instructions.txt new file mode 100644 index 00000000..1364ed65 --- /dev/null +++ b/flask_security/templates/security/email/login_instructions.txt @@ -0,0 +1,5 @@ +Welcome {{ user.email }}! + +You can log into your through the link below: + +{{ login_link }} \ No newline at end of file diff --git a/flask_security/templates/security/logins/passwordless.html b/flask_security/templates/security/logins/passwordless.html new file mode 100644 index 00000000..ee498711 --- /dev/null +++ b/flask_security/templates/security/logins/passwordless.html @@ -0,0 +1,8 @@ +{% include "security/messages.html" %} +

    Login

    +
    + {{ login_form.hidden_tag() }} + {{ login_form.email.label }} {{ login_form.email }}
    + {{ login_form.next }} + {{ login_form.submit }} +
    \ No newline at end of file diff --git a/flask_security/utils.py b/flask_security/utils.py index 294d8931..2680ec57 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -22,7 +22,8 @@ from flask.ext.principal import Identity, AnonymousIdentity, identity_changed from werkzeug.local import LocalProxy -from .signals import user_registered, password_reset_requested +from .signals import user_registered, password_reset_requested, \ + login_instructions_sent # Convenient references @@ -216,6 +217,21 @@ def send_mail(subject, recipient, template, context=None): mail.send(msg) +@contextmanager +def capture_passwordless_login_requests(): + login_requests = [] + + def _on(data, app): + login_requests.append(data) + + login_instructions_sent.connect(_on) + + try: + yield login_requests + finally: + login_instructions_sent.disconnect(_on) + + @contextmanager def capture_registrations(): """Testing utility for capturing registrations. diff --git a/flask_security/views.py b/flask_security/views.py index 7d02d199..a77b4dc1 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -9,18 +9,18 @@ :license: MIT, see LICENSE for more details. """ -from flask import current_app as app, redirect, request, session, \ +from flask import current_app as app, redirect, request, \ render_template, jsonify, Blueprint -from flask.ext.principal import AnonymousIdentity, identity_changed from werkzeug.datastructures import MultiDict from werkzeug.local import LocalProxy from flask_security.confirmable import confirm_by_token, reset_confirmation_token from flask_security.decorators import login_required from flask_security.exceptions import ConfirmationError, BadCredentialsError, \ - ResetPasswordError + ResetPasswordError, PasswordlessLoginError from flask_security.forms import LoginForm, RegisterForm, ForgotPasswordForm, \ - ResetPasswordForm, ResendConfirmationForm + ResetPasswordForm, ResendConfirmationForm, PasswordlessLoginForm +from flask_security.passwordless import send_login_instructions, login_by_token from flask_security.recoverable import reset_by_token, \ reset_password_reset_token from flask_security.signals import user_registered @@ -65,8 +65,8 @@ def _json_auth_error(msg): def authenticate(): """View function which handles an authentication request.""" - - form = LoginForm(MultiDict(request.json) if request.json else request.form) + form_data = MultiDict(request.json) if request.json else request.form + form = LoginForm(form_data) try: user = _security.auth_provider.authenticate(form) @@ -77,7 +77,7 @@ def authenticate(): return redirect(get_post_login_redirect()) - raise BadCredentialsError('Account is disabled') + raise BadCredentialsError(get_message('DISABLED_ACCOUNT')[0]) except BadCredentialsError, e: msg = str(e) @@ -131,6 +131,39 @@ def register_user(): register_user_form=form) +def send_login(): + form = PasswordlessLoginForm() + + user = _datastore.find_user(**form.to_dict()) + + if user.is_active(): + send_login_instructions(user, form.next.data) + msg, cat = get_message('LOGIN_EMAIL_SENT', email=user.email) + else: + msg, cat = get_message('DISABLED_ACCOUNT') + + do_flash(msg, cat) + + return render_template('security/logins/passwordless.html', login_form=form) + + +def token_login(token): + try: + user, next = login_by_token(token) + + except PasswordlessLoginError, e: + msg, cat = str(e), 'error' + + if e.user: + send_login_instructions(e.user, e.next) + + do_flash(msg, cat) + + return redirect(request.referrer or _security.login_manager.login_view) + + return redirect(next or _security.post_login_view) + + def send_confirmation(): form = ResendConfirmationForm(csrf_enabled=not app.testing) @@ -237,9 +270,18 @@ def reset_password(token): def create_blueprint(app, name, import_name, **kwargs): bp = Blueprint(name, import_name, **kwargs) - bp.route(config_value('AUTH_URL', app=app), - methods=['POST'], - endpoint='authenticate')(authenticate) + if config_value('PASSWORDLESS', app=app): + bp.route(config_value('AUTH_URL', app=app), + methods=['POST'], + endpoint='send_login')(send_login) + + bp.route(config_value('AUTH_URL', app=app) + '/', + methods=['GET'], + endpoint='token_login')(token_login) + else: + bp.route(config_value('AUTH_URL', app=app), + methods=['POST'], + endpoint='authenticate')(authenticate) bp.route(config_value('LOGOUT_URL', app=app), endpoint='logout')(login_required(logout)) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 2aa236d1..ff04344f 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -11,7 +11,7 @@ import json from flask.ext.security.utils import capture_registrations, \ - capture_reset_password_requests + capture_reset_password_requests, capture_passwordless_login_requests from werkzeug.utils import parse_cookie from example import app @@ -36,8 +36,6 @@ def test_login_view(self): r = self._get('/login') self.assertIn('Login Page', r.data) - - def test_authenticate(self): r = self.authenticate() self.assertIn('Hello matt@lp.com', r.data) @@ -462,6 +460,70 @@ def test_did_track(self): self.assertEquals(2, user.login_count) +class PasswordlessTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_PASSWORDLESS': True, + 'SECURITY_LOGIN_WITHIN': '1 seconds' + } + + def test_login_requset_for_inactive_user(self): + msg = self.app.config['SECURITY_MSG_DISABLED_ACCOUNT'][0] + r = self.client.post('/auth', data=dict(email='tiya@lp.com'), follow_redirects=True) + self.assertIn(msg, r.data) + + def test_request_login_token_sends_email_and_can_login(self): + e = 'matt@lp.com' + r, user, token = None, None, None + + with capture_passwordless_login_requests() as requests: + with self.app.mail.record_messages() as outbox: + r = self.client.post('/auth', data=dict(email=e), follow_redirects=True) + + self.assertEqual(len(outbox), 1) + + self.assertEquals(1, len(requests)) + self.assertIn('user', requests[0]) + self.assertIn('login_token', requests[0]) + + user = requests[0]['user'] + token = requests[0]['login_token'] + + msg = self.app.config['SECURITY_MSG_LOGIN_EMAIL_SENT'][0] % dict(email=user.email) + self.assertIn(msg, r.data) + + r = self.client.get('/auth/' + token, follow_redirects=True) + self.assertIn('Hello ' + e, r.data) + + r = self.client.get('/profile') + self.assertIn('Profile Page', r.data) + + def test_expired_login_token_sends_email(self): + e = 'matt@lp.com' + + with capture_passwordless_login_requests() as requests: + self.client.post('/auth', data=dict(email=e), follow_redirects=True) + token = requests[0]['login_token'] + + time.sleep(3) + + with self.app.mail.record_messages() as outbox: + r = self.client.get('/auth/' + token, follow_redirects=True) + + self.assertEqual(len(outbox), 1) + self.assertIn(e, outbox[0].html) + self.assertNotIn(token, outbox[0].html) + + expire_text = self.AUTH_CONFIG['SECURITY_LOGIN_WITHIN'] + msg = self.app.config['SECURITY_MSG_LOGIN_EXPIRED'][0] % dict(within=expire_text, email=e) + self.assertIn(msg, r.data) + + def test_invalid_login_token(self): + msg = self.app.config['SECURITY_MSG_INVALID_LOGIN_TOKEN'][0] + r = self._get('/auth/bogus', follow_redirects=True) + self.assertIn(msg, r.data) + + class MongoEngineSecurityTests(DefaultSecurityTests): def _create_app(self, auth_config): From 64467b0338bd38e07aa3571e2bb04d553dc05851 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 15 Aug 2012 10:14:06 -0400 Subject: [PATCH 132/234] Try and fix build --- tests/functional_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index ff04344f..b500ad8a 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -464,7 +464,7 @@ class PasswordlessTests(SecurityTest): AUTH_CONFIG = { 'SECURITY_PASSWORDLESS': True, - 'SECURITY_LOGIN_WITHIN': '1 seconds' + 'SECURITY_LOGIN_WITHIN': '2 seconds' } def test_login_requset_for_inactive_user(self): From 022836c43c1704d12687293a7dbe295ce1a3f978 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 15 Aug 2012 10:25:07 -0400 Subject: [PATCH 133/234] fix tests, hopefully --- tests/functional_tests.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index b500ad8a..6188041b 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -464,7 +464,6 @@ class PasswordlessTests(SecurityTest): AUTH_CONFIG = { 'SECURITY_PASSWORDLESS': True, - 'SECURITY_LOGIN_WITHIN': '2 seconds' } def test_login_requset_for_inactive_user(self): @@ -498,6 +497,19 @@ def test_request_login_token_sends_email_and_can_login(self): r = self.client.get('/profile') self.assertIn('Profile Page', r.data) + def test_invalid_login_token(self): + msg = self.app.config['SECURITY_MSG_INVALID_LOGIN_TOKEN'][0] + r = self._get('/auth/bogus', follow_redirects=True) + self.assertIn(msg, r.data) + + +class ExpiredLoginTokenTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_PASSWORDLESS': True, + 'SECURITY_LOGIN_WITHIN': '1 seconds' + } + def test_expired_login_token_sends_email(self): e = 'matt@lp.com' @@ -518,11 +530,6 @@ def test_expired_login_token_sends_email(self): msg = self.app.config['SECURITY_MSG_LOGIN_EXPIRED'][0] % dict(within=expire_text, email=e) self.assertIn(msg, r.data) - def test_invalid_login_token(self): - msg = self.app.config['SECURITY_MSG_INVALID_LOGIN_TOKEN'][0] - r = self._get('/auth/bogus', follow_redirects=True) - self.assertIn(msg, r.data) - class MongoEngineSecurityTests(DefaultSecurityTests): From 7554a527327c581551110e8f05b14b90b113ee78 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 15 Aug 2012 11:56:26 -0400 Subject: [PATCH 134/234] Cleanup and some more messaging additions --- flask_security/core.py | 10 ++++++---- flask_security/forms.py | 4 +--- flask_security/views.py | 16 ++++++++-------- tests/__init__.py | 3 +++ tests/functional_tests.py | 22 +++++++++++++++++----- 5 files changed, 35 insertions(+), 20 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 95f32067..b6d83e54 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -69,8 +69,8 @@ 'DEFAULT_HTTP_AUTH_REALM': 'Login Required' } -#: Default Flask-Security flash messages -_default_flash_messages = { +#: Default Flask-Security messages +_default_messages = { 'UNAUTHORIZED': ('You do not have permission to view this resource.', 'error'), 'EMAIL_CONFIRMED': ('Your email has been confirmed. You may now log in.', 'success'), 'ALREADY_CONFIRMED': ('Your email has already been confirmed', 'info'), @@ -78,12 +78,14 @@ 'PASSWORD_RESET_REQUEST': ('Instructions to reset your password have been sent to %(email)s.', 'info'), 'PASSWORD_RESET_EXPIRED': ('You did not reset your password within %(within)s. New instructions have been sent to %(email)s.', 'error'), 'INVALID_RESET_PASSWORD_TOKEN': ('Invalid reset password token', 'error'), + 'CONFIRMATION_REQUIRED': ('Email requires confirmation', 'error'), 'CONFIRMATION_REQUEST': ('A new confirmation code has been sent to %(email)s.', 'info'), 'CONFIRMATION_EXPIRED': ('You did not confirm your email within %(within)s. New instructions to confirm your email have been sent to %(email)s.', 'error'), 'LOGIN_EXPIRED': ('You did not login within %(within)s. New instructions to login to your account have been sent to %(email)s.', 'error'), 'LOGIN_EMAIL_SENT': ('Instructions to log in to your account have been sent to %(email)s', 'success'), 'INVALID_LOGIN_TOKEN': ('Invalid login token', 'error'), - 'DISABLED_ACCOUNT': ('Account is disabled', 'error') + 'DISABLED_ACCOUNT': ('Account is disabled', 'error'), + 'PASSWORDLESS_LOGIN_SUCCESSFUL': ('You have successfuly logged in', 'success') } @@ -233,7 +235,7 @@ def init_app(self, app, datastore=None, register_blueprint=True): for key, value in _default_config.items(): app.config.setdefault('SECURITY_' + key, value) - for key, value in _default_flash_messages.items(): + for key, value in _default_messages.items(): app.config.setdefault('SECURITY_MSG_' + key, value) identity_loaded.connect_via(app)(_on_identity_loaded) diff --git a/flask_security/forms.py b/flask_security/forms.py index c779a473..968fb359 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -92,9 +92,7 @@ class LoginForm(Form, EmailFormMixin, PasswordFormMixin): def __init__(self, *args, **kwargs): super(LoginForm, self).__init__(*args, **kwargs) - - if request.method == 'GET': - self.next.data = request.args.get('next', None) + self.next.data = request.args.get('next', None) class RegisterForm(Form, diff --git a/flask_security/views.py b/flask_security/views.py index a77b4dc1..1e1a1fd0 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -15,6 +15,7 @@ from werkzeug.local import LocalProxy from flask_security.confirmable import confirm_by_token, reset_confirmation_token +from flask_security.core import current_user from flask_security.decorators import login_required from flask_security.exceptions import ConfirmationError, BadCredentialsError, \ ResetPasswordError, PasswordlessLoginError @@ -138,16 +139,17 @@ def send_login(): if user.is_active(): send_login_instructions(user, form.next.data) - msg, cat = get_message('LOGIN_EMAIL_SENT', email=user.email) + do_flash(get_message('LOGIN_EMAIL_SENT', email=user.email)) else: - msg, cat = get_message('DISABLED_ACCOUNT') - - do_flash(msg, cat) + do_flash(get_message('DISABLED_ACCOUNT')) return render_template('security/logins/passwordless.html', login_form=form) def token_login(token): + if current_user.is_authenticated(): + return redirect(_security.post_login_view) + try: user, next = login_by_token(token) @@ -161,6 +163,8 @@ def token_login(token): return redirect(request.referrer or _security.login_manager.login_view) + do_flash(get_message('PASSWORDLESS_LOGIN_SUCCESSFUL')) + return redirect(next or _security.post_login_view) @@ -196,10 +200,6 @@ def confirm_email(token): if e.user: reset_confirmation_token(e.user) - msg, cat = get_message('CONFIRMATION_EXPIRED', - within=_security.confirm_email_within, - email=e.user.email) - do_flash(msg, cat) return redirect(get_url(_security.confirm_error_view)) diff --git a/tests/__init__.py b/tests/__init__.py index 9440844b..3c005c4b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -71,3 +71,6 @@ def assertIsNotNone(self, obj, msg=None): return TestCase.assertIsNotNone(self, obj, msg) return self.assertTrue(obj is not None) + + def get_message(self, key, **kwargs): + return self.app.config['SECURITY_MSG_' + key][0] % kwargs diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 6188041b..a4fd1fb8 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -101,7 +101,7 @@ def test_roles_accepted(self): def test_unauthenticated_role_required(self): r = self._get('/admin', follow_redirects=True) - self.assertIn('You do not have permission to view this resource', r.data) + self.assertIn(self.get_message('UNAUTHORIZED'), r.data) def test_multiple_role_required(self): for user in ("matt@lp.com", "joe@lp.com"): @@ -270,7 +270,7 @@ def test_login_before_confirmation(self): e = 'dude@lp.com' self.register(e) r = self.authenticate(email=e) - self.assertIn('Email requires confirmation', r.data) + self.assertIn(self.get_message('CONFIRMATION_REQUIRED'), r.data) def test_register_sends_confirmation_email(self): e = 'dude@lp.com' @@ -397,7 +397,8 @@ def test_reset_password_with_invalid_token(self): 'password': 'newpassword', 'password_confirm': 'newpassword' }, follow_redirects=True) - self.assertIn('Invalid reset password token', r.data) + + self.assertIn(self.get_message('INVALID_RESET_PASSWORD_TOKEN'), r.data) def test_reset_password_twice_flashes_invalid_token_msg(self): with capture_reset_password_requests() as requests: @@ -412,7 +413,7 @@ def test_reset_password_twice_flashes_invalid_token_msg(self): url = '/reset/' + t r = self.client.post(url, data=data, follow_redirects=True) r = self.client.post(url, data=data, follow_redirects=True) - self.assertIn('Invalid reset password token', r.data) + self.assertIn(self.get_message('INVALID_RESET_PASSWORD_TOKEN'), r.data) class ExpiredResetPasswordTest(SecurityTest): @@ -492,7 +493,7 @@ def test_request_login_token_sends_email_and_can_login(self): self.assertIn(msg, r.data) r = self.client.get('/auth/' + token, follow_redirects=True) - self.assertIn('Hello ' + e, r.data) + self.assertIn(self.get_message('PASSWORDLESS_LOGIN_SUCCESSFUL'), r.data) r = self.client.get('/profile') self.assertIn('Profile Page', r.data) @@ -502,6 +503,17 @@ def test_invalid_login_token(self): r = self._get('/auth/bogus', follow_redirects=True) self.assertIn(msg, r.data) + def test_token_login_forwards_to_post_login_view_when_already_authenticated(self): + with capture_passwordless_login_requests() as requests: + self.client.post('/auth', data=dict(email='matt@lp.com'), follow_redirects=True) + token = requests[0]['login_token'] + + r = self.client.get('/auth/' + token, follow_redirects=True) + self.assertIn(self.get_message('PASSWORDLESS_LOGIN_SUCCESSFUL'), r.data) + + r = self.client.get('/auth/' + token, follow_redirects=True) + self.assertNotIn(self.get_message('PASSWORDLESS_LOGIN_SUCCESSFUL'), r.data) + class ExpiredLoginTokenTests(SecurityTest): From 8c107dcb7d03e733a4e116d2969ae866a8d58966 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 15 Aug 2012 13:30:14 -0400 Subject: [PATCH 135/234] Add setup.cfg --- setup.cfg | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 setup.cfg diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..736bfed3 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,6 @@ +[build_sphinx] +source-dir = docs/ +build-dir = docs/_build + +[upload_sphinx] +upload-dir = docs/_build/html \ No newline at end of file From bee5f1cbe9eba2b7c750fccdf82c3f461771a2a8 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 15 Aug 2012 15:36:47 -0400 Subject: [PATCH 136/234] Update version number --- docs/conf.py | 5 ++--- flask_security/__init__.py | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 17cae5d6..6bc77760 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,6 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath('..')) sys.path.append(os.path.abspath('_themes')) -#from setup import __version__ # -- General configuration ----------------------------------------------------- @@ -50,7 +49,7 @@ # built documents. # # The short X.Y version. -version = '1.2.1' +version = '1.3.0-dev' # The full version, including alpha/beta/rc tags. release = version @@ -99,7 +98,7 @@ # further. For a list of options available for each theme, see the # documentation. html_theme_options = { - 'github_fork': 'mattupstate/flask-security', + 'github_fork': 'mattupstate/flask-security', 'index_logo': False } diff --git a/flask_security/__init__.py b/flask_security/__init__.py index 31858b05..2abe3a21 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -10,6 +10,8 @@ :license: MIT, see LICENSE for more details. """ +__version__ = '1.3.0-dev' + from .core import Security, RoleMixin, UserMixin, AnonymousUser, \ AuthenticationProvider, current_user from .datastore import SQLAlchemyUserDatastore, MongoEngineUserDatastore From b712be346c30403dc9148f001c5ad31996b7188f Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 15 Aug 2012 15:38:04 -0400 Subject: [PATCH 137/234] Add release script --- scripts/release.py | 189 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100755 scripts/release.py diff --git a/scripts/release.py b/scripts/release.py new file mode 100755 index 00000000..b355b82d --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" + make-release + ~~~~~~~~~~~~ + + Helper script that performs a release. Does pretty much everything + automatically for us. + + :copyright: (c) 2011 by Armin Ronacher. + :license: BSD, see LICENSE for more details. +""" +import sys +import os +import re +from datetime import datetime, date +from subprocess import Popen, PIPE + +_date_clean_re = re.compile(r'(\d+)(st|nd|rd|th)') + + +def installed_libraries(): + return set(Popen(['pip', 'freeze'], stdout=PIPE).communicate()[0].splitlines()) + + +def has_library_installed(library): + return library + '==' in installed_libraries() + + +def parse_changelog(): + with open('CHANGES') as f: + lineiter = iter(f) + for line in lineiter: + match = re.search('^Version\s+(.*)', line.strip()) + + if match is None: + continue + + version = match.group(1).strip() + + if lineiter.next().count('-') != len(line.strip()): + fail('Invalid hyphen count below version line: %s', line.strip()) + + while 1: + released = lineiter.next().strip() + if released: + break + + match = re.search(r'Released (\w+\s+\d+\w+\s+\d+)', released) + + if match is None: + fail('Could not find release date in version %s' % version) + + datestr = parse_date(match.group(1).strip()) + + return version, datestr + + +def bump_version(version): + try: + parts = map(int, version.split('.')) + except ValueError: + fail('Current version is not numeric') + parts[-1] += 1 + return '.'.join(map(str, parts)) + + +def parse_date(string): + string = _date_clean_re.sub(r'\1', string) + return datetime.strptime(string, '%B %d %Y') + + +def set_filename_version(filename, version_number, pattern): + changed = [] + + def inject_version(match): + before, old, after = match.groups() + changed.append(True) + return before + version_number + after + + with open(filename) as f: + contents = re.sub(r"^(\s*%s\s*=\s*')(.+?)(')(?sm)" % pattern, + inject_version, f.read()) + + if not changed: + fail('Could not find %s in %s', pattern, filename) + + with open(filename, 'w') as f: + f.write(contents) + + +def set_init_version(version): + info('Setting __init__.py version to %s', version) + set_filename_version('flask_security/__init__.py', version, '__version__') + + +def set_setup_version(version): + info('Setting setup.py version to %s', version) + set_filename_version('setup.py', version, 'version') + + +def set_docs_version(version): + info('Setting docs/conf.py version to %s', version) + set_filename_version('docs/conf.py', version, 'version') + + +def build_and_upload(): + Popen([sys.executable, 'setup.py', 'sdist', 'build_sphinx', 'upload', 'upload_sphinx']).wait() + + +def fail(message, *args): + print >> sys.stderr, 'Error:', message % args + sys.exit(1) + + +def info(message, *args): + print >> sys.stderr, message % args + + +def get_git_tags(): + return set(Popen(['git', 'tag'], stdout=PIPE).communicate()[0].splitlines()) + + +def git_is_clean(): + return Popen(['git', 'diff', '--quiet']).wait() == 0 + + +def make_git_commit(message, *args): + message = message % args + Popen(['git', 'commit', '-am', message]).wait() + + +def make_git_tag(tag): + info('Tagging "%s"', tag) + Popen(['git', 'tag', '-a', tag, '-m', '%s release' % tag]).wait() + Popen(['git', 'push', '--tags']).wait() + + +def update_version(version): + for f in [set_init_version, set_setup_version, set_docs_version]: + f(version) + + +def get_branches(): + return set(Popen(['git', 'branch'], stdout=PIPE).communicate()[0].splitlines()) + + +def branch_is(branch): + return '* ' + branch in get_branches() + + +def main(): + os.chdir(os.path.join(os.path.dirname(__file__), '..')) + + rv = parse_changelog() + + if rv is None: + fail('Could not parse changelog') + + version, release_date = rv + + tags = get_git_tags() + + if version in tags: + fail('Version "%s" is already tagged', version) + if release_date.date() != date.today(): + fail('Release date is not today') + + if not branch_is('master'): + fail('You are not on the master branch') + + if not git_is_clean(): + fail('You have uncommitted changes in git') + + for lib in ['Sphinx', 'Sphinx-PyPI-upload']: + if not has_library_installed(lib): + fail('Build requires that %s be installed', lib) + + info('Releasing %s (release date %s)', + version, release_date.strftime('%d/%m/%Y')) + + update_version(version) + make_git_commit('Bump version number to %s', version) + make_git_tag(version) + build_and_upload() + + +if __name__ == '__main__': + main() From 72ecb562508a3af3ce94a9455df9564fe5f6afe0 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 15 Aug 2012 16:05:27 -0400 Subject: [PATCH 138/234] Update release script --- scripts/release.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index b355b82d..c8e06f3e 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -20,7 +20,7 @@ def installed_libraries(): - return set(Popen(['pip', 'freeze'], stdout=PIPE).communicate()[0].splitlines()) + return Popen(['pip', 'freeze'], stdout=PIPE).communicate()[0] def has_library_installed(library): @@ -91,7 +91,7 @@ def inject_version(match): def set_init_version(version): info('Setting __init__.py version to %s', version) - set_filename_version('flask_security/__init__.py', version, '__version__') + set_filename_version('flask_principal/__init__.py', version, '__version__') def set_setup_version(version): @@ -133,7 +133,6 @@ def make_git_commit(message, *args): def make_git_tag(tag): info('Tagging "%s"', tag) Popen(['git', 'tag', '-a', tag, '-m', '%s release' % tag]).wait() - Popen(['git', 'push', '--tags']).wait() def update_version(version): @@ -161,6 +160,10 @@ def main(): tags = get_git_tags() + for lib in ['Sphinx', 'Sphinx-PyPI-upload']: + if not has_library_installed(lib): + fail('Build requires that %s be installed', lib) + if version in tags: fail('Version "%s" is already tagged', version) if release_date.date() != date.today(): @@ -172,10 +175,6 @@ def main(): if not git_is_clean(): fail('You have uncommitted changes in git') - for lib in ['Sphinx', 'Sphinx-PyPI-upload']: - if not has_library_installed(lib): - fail('Build requires that %s be installed', lib) - info('Releasing %s (release date %s)', version, release_date.strftime('%d/%m/%Y')) From f4d305cc0381c57a8125ebdbb51aeeb7693f7dd6 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 15 Aug 2012 16:07:51 -0400 Subject: [PATCH 139/234] Update CHANGES --- CHANGES | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGES b/CHANGES index 8eabbeeb..4226020b 100644 --- a/CHANGES +++ b/CHANGES @@ -3,10 +3,17 @@ Flask-Security Changelog Here you can see the full list of changes between each Flask-Security release. +Version 1.2.3 +------------- + +Released June 12th 2012 + +- Fixed a bug in the RoleMixin eq/ne functions + Version 1.2.2 ------------- -Released ... +Released April 27th 2012 - Fixed bug where `roles_required` and `roles_accepted` did not pass the next argument to the login view @@ -14,7 +21,7 @@ Released ... Version 1.2.1 ------------- -Released March 28th, 2012 +Released March 28th 2012 - Added optional user model mixin parameter for datastores - Added CreateRoleCommand to available Flask-Script commands @@ -22,7 +29,7 @@ Released March 28th, 2012 Version 1.2.0 ------------- -Released March 12th, 2012 +Released March 12th 2012 - Added configuration option `SECURITY_FLASH_MESSAGES` which can be set to a boolean value to specify if Flask-Security should flash messages or not. From 80144c85a1fa634973f9d0e47ad4daa4a5ab4a4b Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 15 Aug 2012 17:06:07 -0400 Subject: [PATCH 140/234] Fix bug with an invalid remember_token cookie value --- flask_security/core.py | 12 ++++++++---- tests/functional_tests.py | 7 +++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index b6d83e54..a3554ce7 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -9,7 +9,7 @@ :license: MIT, see LICENSE for more details. """ -from itsdangerous import URLSafeTimedSerializer +from itsdangerous import URLSafeTimedSerializer, BadSignature from flask import current_app from flask.ext.login import AnonymousUser as AnonymousUserBase, \ UserMixin as BaseUserMixin, LoginManager, current_user @@ -97,9 +97,13 @@ def _user_loader(user_id): def _token_loader(token): - data = _security.remember_token_serializer.loads(token) - user = _security.datastore.find_user(id=data[0]) - return user if md5(user.password) == data[1] else None + try: + data = _security.remember_token_serializer.loads(token) + user = _security.datastore.find_user(id=data[0]) + return user if md5(user.password) == data[1] else None + except: + print 'word' + return None def _identity_loader(): diff --git a/tests/functional_tests.py b/tests/functional_tests.py index a4fd1fb8..d96b8479 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -5,6 +5,8 @@ import base64 import time +from cookielib import Cookie + try: import simplejson as json except ImportError: @@ -190,6 +192,11 @@ def test_remember_token(self): r = self._get('/profile') self.assertIn('profile', r.data) + def test_token_loader_does_not_fail_with_invalid_token(self): + self.client.cookie_jar.set_cookie(Cookie(version=0, name='remember_token', value='None', port=None, port_specified=False, domain='www.example.com', domain_specified=False, domain_initial_dot=False, path='/', path_specified=True, secure=False, expires=None, discard=True, comment=None, comment_url=None, rest={'HttpOnly': None}, rfc2109=False)) + r = self._get('/') + self.assertNotIn('BadSignature', r.data) + class ConfiguredSecurityTests(SecurityTest): From ec05b301067e90bbabaf4e341a544539df474497 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 15 Aug 2012 17:23:42 -0400 Subject: [PATCH 141/234] Get rid of silly print statement --- flask_security/core.py | 1 - 1 file changed, 1 deletion(-) diff --git a/flask_security/core.py b/flask_security/core.py index a3554ce7..d7c4dcd0 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -102,7 +102,6 @@ def _token_loader(token): user = _security.datastore.find_user(id=data[0]) return user if md5(user.password) == data[1] else None except: - print 'word' return None From 1b505d0f3956f7a87b75ed27a69c2c2e6fc147ab Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 15 Aug 2012 19:22:06 -0400 Subject: [PATCH 142/234] Update default templates --- .../templates/security/confirmations/new.html | 5 +++-- .../templates/security/logins/new.html | 11 ++++++----- .../templates/security/logins/passwordless.html | 7 ++++--- flask_security/templates/security/macros.html | 16 ++++++++++++++++ .../templates/security/passwords/edit.html | 7 ++++--- .../templates/security/passwords/new.html | 5 +++-- .../templates/security/registrations/edit.html | 13 +++++++------ .../templates/security/registrations/new.html | 9 +++++---- 8 files changed, 48 insertions(+), 25 deletions(-) create mode 100644 flask_security/templates/security/macros.html diff --git a/flask_security/templates/security/confirmations/new.html b/flask_security/templates/security/confirmations/new.html index 7bece483..e3e523c4 100644 --- a/flask_security/templates/security/confirmations/new.html +++ b/flask_security/templates/security/confirmations/new.html @@ -1,7 +1,8 @@ +{% from "security/macros.html" import render_field_with_errors, render_field %} {% include "security/messages.html" %}

    Resend confirmation instructions

    {{ reset_confirmation_form.hidden_tag() }} - {{ reset_confirmation_form.email.label }} {{ reset_confirmation_form.email }} - {{ reset_confirmation_form.submit }} + {{ render_field_with_errors(reset_confirmation_form.email) }} + {{ render_field(reset_confirmation_form.submit) }}
    \ No newline at end of file diff --git a/flask_security/templates/security/logins/new.html b/flask_security/templates/security/logins/new.html index f5d9998b..a81d1fe1 100644 --- a/flask_security/templates/security/logins/new.html +++ b/flask_security/templates/security/logins/new.html @@ -1,10 +1,11 @@ +{% from "security/macros.html" import render_field_with_errors, render_field %} {% include "security/messages.html" %}

    Login

    {{ login_form.hidden_tag() }} - {{ login_form.email.label }} {{ login_form.email }}
    - {{ login_form.password.label }} {{ login_form.password }}
    - {{ login_form.remember.label }} {{ login_form.remember }}
    - {{ login_form.next }} - {{ login_form.submit }} + {{ render_field_with_errors(login_form.email) }} + {{ render_field_with_errors(login_form.password) }} + {{ render_field_with_errors(login_form.remember) }} + {{ render_field(login_form.next) }} + {{ render_field(login_form.submit) }}
    \ No newline at end of file diff --git a/flask_security/templates/security/logins/passwordless.html b/flask_security/templates/security/logins/passwordless.html index ee498711..d0f353e6 100644 --- a/flask_security/templates/security/logins/passwordless.html +++ b/flask_security/templates/security/logins/passwordless.html @@ -1,8 +1,9 @@ +{% from "security/macros.html" import render_field_with_errors, render_field %} {% include "security/messages.html" %}

    Login

    {{ login_form.hidden_tag() }} - {{ login_form.email.label }} {{ login_form.email }}
    - {{ login_form.next }} - {{ login_form.submit }} + {{ render_field_with_errors(login_form.email) }} + {{ render_field(login_form.next) }} + {{ render_field(login_form.submit) }}
    \ No newline at end of file diff --git a/flask_security/templates/security/macros.html b/flask_security/templates/security/macros.html new file mode 100644 index 00000000..8575f3db --- /dev/null +++ b/flask_security/templates/security/macros.html @@ -0,0 +1,16 @@ +{% macro render_field_with_errors(field) %} +

    + {{ field.label }} {{ field(**kwargs)|safe }} + {% if field.errors %} +

      + {% for error in field.errors %} +
    • {{ error }}
    • + {% endfor %} +
    + {% endif %} +

    +{% endmacro %} + +{% macro render_field(field) %} +

    {{ field(**kwargs)|safe }}

    +{% endmacro %} \ No newline at end of file diff --git a/flask_security/templates/security/passwords/edit.html b/flask_security/templates/security/passwords/edit.html index e806d0d2..4a505bec 100644 --- a/flask_security/templates/security/passwords/edit.html +++ b/flask_security/templates/security/passwords/edit.html @@ -1,8 +1,9 @@ +{% from "security/macros.html" import render_field_with_errors, render_field %} {% include "security/messages.html" %}

    Change password

    {{ reset_password_form.hidden_tag() }} - {{ reset_password_form.password.label }} {{ reset_password_form.password }}
    - {{ reset_password_form.password_confirm.label }} {{ reset_password_form.password_confirm }}
    - {{ reset_password_form.submit }} + {{ render_field_with_errors(reset_password_form.password) }} + {{ render_field_with_errors(reset_password_form.password_confirm) }} + {{ render_field(reset_password_form.submit) }}
    \ No newline at end of file diff --git a/flask_security/templates/security/passwords/new.html b/flask_security/templates/security/passwords/new.html index c5b2a934..31c4f1f1 100644 --- a/flask_security/templates/security/passwords/new.html +++ b/flask_security/templates/security/passwords/new.html @@ -1,7 +1,8 @@ +{% from "security/macros.html" import render_field_with_errors, render_field %} {% include "security/messages.html" %}

    Send reset password instructions

    {{ forgot_password_form.hidden_tag() }} - {{ forgot_password_form.email.label }} {{ forgot_password_form.email }} - {{ forgot_password_form.submit }} + {{ render_field_with_errors(forgot_password_form.email) }} + {{ render_field(forgot_password_form.submit) }}
    \ No newline at end of file diff --git a/flask_security/templates/security/registrations/edit.html b/flask_security/templates/security/registrations/edit.html index de100f76..0d5c6372 100644 --- a/flask_security/templates/security/registrations/edit.html +++ b/flask_security/templates/security/registrations/edit.html @@ -1,10 +1,11 @@ +{% from "security/macros.html" import render_field_with_errors, render_field %} {% include "security/messages.html" %} -

    Edit account

    +

    Edit Account

    {{ edit_user_form.hidden_tag() }} - {{ edit_user_form.email.label }} {{ edit_user_form.email }}
    - {{ edit_user_form.password.label }} {{ edit_user_form.password }}
    - {{ edit_user_form.password_confirm.label }} {{ edit_user_form.password_confirm }}
    - {{ edit_user_form.current_password.label }} {{ edit_user_form.current_password }}
    - {{ edit_user_form.submit }} + {{ render_field_with_errors(edit_user_form.email) }} + {{ render_field_with_errors(edit_user_form.password) }} + {{ render_field_with_errors(edit_user_form.password_confirm) }} + {{ render_field_with_errors(edit_user_form.current_password) }} + {{ render_field(edit_user_form.submit) }}
    \ No newline at end of file diff --git a/flask_security/templates/security/registrations/new.html b/flask_security/templates/security/registrations/new.html index 133f5d89..b9f9773c 100644 --- a/flask_security/templates/security/registrations/new.html +++ b/flask_security/templates/security/registrations/new.html @@ -1,9 +1,10 @@ +{% from "security/macros.html" import render_field_with_errors, render_field %} {% include "security/messages.html" %}

    Register

    {{ register_user_form.hidden_tag() }} - {{ register_user_form.email.label }} {{ register_user_form.email }}
    - {{ register_user_form.password.label }} {{ register_user_form.password }}
    - {{ register_user_form.password_confirm.label }} {{ register_user_form.password_confirm }}
    - {{ register_user_form.submit }} + {{ render_field_with_errors(register_user_form.email) }} + {{ render_field_with_errors(register_user_form.password) }} + {{ render_field_with_errors(register_user_form.password_confirm) }} + {{ render_field(register_user_form.submit) }}
    \ No newline at end of file From bb91c4a81ad89b56cd9e26abfe499c0b848868db Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 15 Aug 2012 19:22:24 -0400 Subject: [PATCH 143/234] Fix flash messages and fix password reset --- flask_security/core.py | 3 ++- flask_security/views.py | 16 +++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index d7c4dcd0..f94bbd4e 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -85,7 +85,8 @@ 'LOGIN_EMAIL_SENT': ('Instructions to log in to your account have been sent to %(email)s', 'success'), 'INVALID_LOGIN_TOKEN': ('Invalid login token', 'error'), 'DISABLED_ACCOUNT': ('Account is disabled', 'error'), - 'PASSWORDLESS_LOGIN_SUCCESSFUL': ('You have successfuly logged in', 'success') + 'PASSWORDLESS_LOGIN_SUCCESSFUL': ('You have successfuly logged in', 'success'), + 'PASSWORD_RESET': ('Your password has successfully been reset. You may now log in.', 'success') } diff --git a/flask_security/views.py b/flask_security/views.py index 1e1a1fd0..6083af11 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -139,9 +139,9 @@ def send_login(): if user.is_active(): send_login_instructions(user, form.next.data) - do_flash(get_message('LOGIN_EMAIL_SENT', email=user.email)) + do_flash(*get_message('LOGIN_EMAIL_SENT', email=user.email)) else: - do_flash(get_message('DISABLED_ACCOUNT')) + do_flash(*get_message('DISABLED_ACCOUNT')) return render_template('security/logins/passwordless.html', login_form=form) @@ -163,7 +163,7 @@ def token_login(token): return redirect(request.referrer or _security.login_manager.login_view) - do_flash(get_message('PASSWORDLESS_LOGIN_SUCCESSFUL')) + do_flash(*get_message('PASSWORDLESS_LOGIN_SUCCESSFUL')) return redirect(next or _security.post_login_view) @@ -204,7 +204,7 @@ def confirm_email(token): return redirect(get_url(_security.confirm_error_view)) - do_flash(get_message('EMAIL_CONFIRMED')) + do_flash(*get_message('EMAIL_CONFIRMED')) return redirect(_security.post_confirm_view or _security.post_login_view) @@ -228,9 +228,6 @@ def forgot_password(): return redirect(_security.post_forgot_view) else: - _logger.debug('A reset password request was made for %s but ' - 'that email does not exist.' % form.email.data) - for key, value in form.errors.items(): do_flash(value[0], 'error') @@ -246,8 +243,13 @@ def reset_password(token): if form.validate_on_submit(): try: user = reset_by_token(token=token, **form.to_dict()) + _logger.debug('%s reset their password' % user) + do_flash(*get_message('PASSWORD_RESET')) + + return redirect(_security.login_manager.login_view) + except ResetPasswordError, e: msg, cat = str(e), 'error' From ac674ecd86765c5bbab2606b29ae49c4a5d81f90 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 15 Aug 2012 19:33:23 -0400 Subject: [PATCH 144/234] Fix some messaging --- flask_security/core.py | 23 ++++++++++++----------- flask_security/views.py | 5 ++++- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index f94bbd4e..6a0d4284 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -47,6 +47,7 @@ 'RESET_PASSWORD_ERROR_VIEW': '/', 'POST_REGISTER_VIEW': None, 'POST_CONFIRM_VIEW': None, + 'POST_RESET_VIEW': None, 'UNAUTHORIZED_VIEW': None, 'DEFAULT_ROLES': [], 'CONFIRMABLE': False, @@ -73,20 +74,20 @@ _default_messages = { 'UNAUTHORIZED': ('You do not have permission to view this resource.', 'error'), 'EMAIL_CONFIRMED': ('Your email has been confirmed. You may now log in.', 'success'), - 'ALREADY_CONFIRMED': ('Your email has already been confirmed', 'info'), - 'INVALID_CONFIRMATION_TOKEN': ('Invalid confirmation token', 'error'), + 'ALREADY_CONFIRMED': ('Your email has already been confirmed.', 'info'), + 'INVALID_CONFIRMATION_TOKEN': ('Invalid confirmation token.', 'error'), 'PASSWORD_RESET_REQUEST': ('Instructions to reset your password have been sent to %(email)s.', 'info'), 'PASSWORD_RESET_EXPIRED': ('You did not reset your password within %(within)s. New instructions have been sent to %(email)s.', 'error'), - 'INVALID_RESET_PASSWORD_TOKEN': ('Invalid reset password token', 'error'), - 'CONFIRMATION_REQUIRED': ('Email requires confirmation', 'error'), + 'INVALID_RESET_PASSWORD_TOKEN': ('Invalid reset password token.', 'error'), + 'CONFIRMATION_REQUIRED': ('Email requires confirmation.', 'error'), 'CONFIRMATION_REQUEST': ('A new confirmation code has been sent to %(email)s.', 'info'), 'CONFIRMATION_EXPIRED': ('You did not confirm your email within %(within)s. New instructions to confirm your email have been sent to %(email)s.', 'error'), 'LOGIN_EXPIRED': ('You did not login within %(within)s. New instructions to login to your account have been sent to %(email)s.', 'error'), - 'LOGIN_EMAIL_SENT': ('Instructions to log in to your account have been sent to %(email)s', 'success'), - 'INVALID_LOGIN_TOKEN': ('Invalid login token', 'error'), - 'DISABLED_ACCOUNT': ('Account is disabled', 'error'), - 'PASSWORDLESS_LOGIN_SUCCESSFUL': ('You have successfuly logged in', 'success'), - 'PASSWORD_RESET': ('Your password has successfully been reset. You may now log in.', 'success') + 'LOGIN_EMAIL_SENT': ('Instructions to log in to your account have been sent to %(email)s.', 'success'), + 'INVALID_LOGIN_TOKEN': ('Invalid login token.', 'error'), + 'DISABLED_ACCOUNT': ('Account is disabled.', 'error'), + 'PASSWORDLESS_LOGIN_SUCCESSFUL': ('You have successfuly logged in.', 'success'), + 'PASSWORD_RESET': ('You successfully reset your password and you have been logged in automatically.', 'success') } @@ -330,10 +331,10 @@ def do_authenticate(self, username_or_email, password): try: user = self._get_user(username_or_email) except exceptions.UserNotFoundError: - raise exceptions.BadCredentialsError('Specified user does not exist') + raise exceptions.BadCredentialsError('Specified user does not exist.') if requires_confirmation(user): - raise exceptions.BadCredentialsError('Email requires confirmation') + raise exceptions.BadCredentialsError('Email requires confirmation.') # compare passwords if verify_password(password, user.password, diff --git a/flask_security/views.py b/flask_security/views.py index 6083af11..ee3b66d0 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -248,7 +248,10 @@ def reset_password(token): do_flash(*get_message('PASSWORD_RESET')) - return redirect(_security.login_manager.login_view) + login_user(user) + + return redirect(_security.post_reset_view or + _security.post_login_view) except ResetPasswordError, e: msg, cat = str(e), 'error' From 2fcfb80e8e6feda1923ac743fa3f51f31a822ac1 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 16 Aug 2012 13:41:13 -0400 Subject: [PATCH 145/234] Refactor templates and added `url_for_security` utility. Can also configure the blueprint name. --- example/templates/_nav.html | 2 +- example/templates/login.html | 2 +- example/templates/passwordless_login.html | 2 +- example/templates/register.html | 2 +- flask_security/__init__.py | 2 +- flask_security/confirmable.py | 4 ++-- flask_security/core.py | 16 +++++++++++----- flask_security/passwordless.py | 7 ++++--- flask_security/recoverable.py | 8 ++++---- .../security/{macros.html => _macros.html} | 0 .../security/{messages.html => _messages.html} | 0 .../templates/security/confirmations/new.html | 8 -------- .../{registrations/edit.html => edit_user.html} | 6 +++--- .../templates/security/forgot_password.html | 8 ++++++++ .../security/{logins/new.html => login.html} | 6 +++--- .../templates/security/logins/passwordless.html | 9 --------- .../templates/security/passwords/new.html | 8 -------- .../new.html => register_user.html} | 6 +++--- .../{passwords/edit.html => reset_password.html} | 6 +++--- .../templates/security/send_confirmation.html | 8 ++++++++ .../templates/security/send_login.html | 9 +++++++++ flask_security/utils.py | 15 +++++++++++++++ flask_security/views.py | 10 +++++----- 23 files changed, 83 insertions(+), 61 deletions(-) rename flask_security/templates/security/{macros.html => _macros.html} (100%) rename flask_security/templates/security/{messages.html => _messages.html} (100%) delete mode 100644 flask_security/templates/security/confirmations/new.html rename flask_security/templates/security/{registrations/edit.html => edit_user.html} (62%) create mode 100644 flask_security/templates/security/forgot_password.html rename flask_security/templates/security/{logins/new.html => login.html} (57%) delete mode 100644 flask_security/templates/security/logins/passwordless.html delete mode 100644 flask_security/templates/security/passwords/new.html rename flask_security/templates/security/{registrations/new.html => register_user.html} (58%) rename flask_security/templates/security/{passwords/edit.html => reset_password.html} (50%) create mode 100644 flask_security/templates/security/send_confirmation.html create mode 100644 flask_security/templates/security/send_login.html diff --git a/example/templates/_nav.html b/example/templates/_nav.html index 3796955e..7dd13473 100644 --- a/example/templates/_nav.html +++ b/example/templates/_nav.html @@ -12,7 +12,7 @@ {% endif -%}
  • {%- if current_user.is_authenticated() -%} - Log out + Log out {%- else -%} Log in {%- endif -%} diff --git a/example/templates/login.html b/example/templates/login.html index 137fc6cc..231bb564 100644 --- a/example/templates/login.html +++ b/example/templates/login.html @@ -1,6 +1,6 @@ {% include "_messages.html" %} {% include "_nav.html" %} -
    + {{ form.hidden_tag() }} {{ form.email.label }} {{ form.email }}
    {{ form.password.label }} {{ form.password }}
    diff --git a/example/templates/passwordless_login.html b/example/templates/passwordless_login.html index 715179b9..18279828 100644 --- a/example/templates/passwordless_login.html +++ b/example/templates/passwordless_login.html @@ -1,6 +1,6 @@ {% include "_messages.html" %} {% include "_nav.html" %} - + {{ form.hidden_tag() }} {{ form.email.label }} {{ form.email }}
    {{ form.next }} diff --git a/example/templates/register.html b/example/templates/register.html index 440fdd22..7f4e27ac 100644 --- a/example/templates/register.html +++ b/example/templates/register.html @@ -1,7 +1,7 @@ {% include "_messages.html" %} {% include "_nav.html" %}

    Register

    - + {{ register_user_form.hidden_tag() }} {{ register_user_form.email.label }} {{ register_user_form.email }}
    {{ register_user_form.password.label }} {{ register_user_form.password }}
    diff --git a/flask_security/__init__.py b/flask_security/__init__.py index 2abe3a21..1f72c84d 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -22,4 +22,4 @@ from .signals import confirm_instructions_sent, password_reset, \ password_reset_requested, reset_instructions_sent, user_confirmed, \ user_registered -from .utils import login_user, logout_user +from .utils import login_user, logout_user, url_for_security diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 9dd5f14a..c2c1d292 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -16,7 +16,7 @@ from werkzeug.local import LocalProxy from .exceptions import ConfirmationError -from .utils import send_mail, get_max_age, md5, get_message +from .utils import send_mail, get_max_age, md5, get_message, url_for_security from .signals import user_confirmed, confirm_instructions_sent @@ -32,7 +32,7 @@ def send_confirmation_instructions(user, token): :param user: The user to send the instructions to :param token: The confirmation token """ - url = url_for('flask_security.confirm_email', token=token) + url = url_for_security('confirm_email', token=token) confirmation_link = request.url_root[:-1] + url diff --git a/flask_security/core.py b/flask_security/core.py index 6a0d4284..b810ac19 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -9,7 +9,7 @@ :license: MIT, see LICENSE for more details. """ -from itsdangerous import URLSafeTimedSerializer, BadSignature +from itsdangerous import URLSafeTimedSerializer from flask import current_app from flask.ext.login import AnonymousUser as AnonymousUserBase, \ UserMixin as BaseUserMixin, LoginManager, current_user @@ -21,7 +21,8 @@ from . import views, exceptions from .confirmable import requires_confirmation -from .utils import config_value as cv, get_config, verify_password, md5 +from .utils import config_value as cv, get_config, verify_password, md5, \ + url_for_security # Convenient references _security = LocalProxy(lambda: current_app.extensions['security']) @@ -29,6 +30,7 @@ #: Default Flask-Security configuration _default_config = { + 'BLUEPRINT_NAME': 'security', 'URL_PREFIX': None, 'FLASH_MESSAGES': True, 'PASSWORD_HASH': 'plaintext', @@ -246,11 +248,15 @@ def init_app(self, app, datastore=None, register_blueprint=True): identity_loaded.connect_via(app)(_on_identity_loaded) if register_blueprint: - bp = views.create_blueprint(app, 'flask_security', __name__, - template_folder='templates', - url_prefix=cv('URL_PREFIX', app=app)) + name = cv('BLUEPRINT_NAME', app=app) + url_prefix = cv('URL_PREFIX', app=app) + bp = views.create_blueprint(app, name, __name__, + url_prefix=url_prefix, + template_folder='templates') app.register_blueprint(bp) + app.context_processor(lambda: dict(url_for_security=url_for_security)) + state = self._get_state(app, datastore or self.datastore) app.extensions['security'] = state diff --git a/flask_security/passwordless.py b/flask_security/passwordless.py index 6820e4c5..cc3332c5 100644 --- a/flask_security/passwordless.py +++ b/flask_security/passwordless.py @@ -9,13 +9,14 @@ :license: MIT, see LICENSE for more details. """ -from flask import url_for, request, current_app as app +from flask import request, current_app as app from itsdangerous import SignatureExpired, BadSignature from werkzeug.local import LocalProxy from .exceptions import PasswordlessLoginError from .signals import login_instructions_sent -from .utils import send_mail, md5, get_max_age, login_user, get_message +from .utils import send_mail, md5, get_max_age, login_user, get_message, \ + url_for_security # Convenient references @@ -32,7 +33,7 @@ def send_login_instructions(user, next): """ token = generate_login_token(user, next) - url = url_for('flask_security.token_login', token=token) + url = url_for_security('token_login', token=token) login_link = request.url_root[:-1] + url diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index ea20aa9e..c6125a3a 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -10,13 +10,14 @@ """ from itsdangerous import BadSignature, SignatureExpired -from flask import current_app as app, request, url_for +from flask import current_app as app, request from werkzeug.local import LocalProxy from .exceptions import ResetPasswordError, UserNotFoundError from .signals import password_reset, password_reset_requested, \ reset_instructions_sent -from .utils import send_mail, get_max_age, md5, get_message, encrypt_password +from .utils import send_mail, get_max_age, md5, get_message, encrypt_password, \ + url_for_security # Convenient references @@ -30,8 +31,7 @@ def send_reset_password_instructions(user, reset_token): :param user: The user to send the instructions to """ - url = url_for('flask_security.reset_password', - token=reset_token) + url = url_for_security('reset_password', token=reset_token) reset_link = request.url_root[:-1] + url diff --git a/flask_security/templates/security/macros.html b/flask_security/templates/security/_macros.html similarity index 100% rename from flask_security/templates/security/macros.html rename to flask_security/templates/security/_macros.html diff --git a/flask_security/templates/security/messages.html b/flask_security/templates/security/_messages.html similarity index 100% rename from flask_security/templates/security/messages.html rename to flask_security/templates/security/_messages.html diff --git a/flask_security/templates/security/confirmations/new.html b/flask_security/templates/security/confirmations/new.html deleted file mode 100644 index e3e523c4..00000000 --- a/flask_security/templates/security/confirmations/new.html +++ /dev/null @@ -1,8 +0,0 @@ -{% from "security/macros.html" import render_field_with_errors, render_field %} -{% include "security/messages.html" %} -

    Resend confirmation instructions

    - - {{ reset_confirmation_form.hidden_tag() }} - {{ render_field_with_errors(reset_confirmation_form.email) }} - {{ render_field(reset_confirmation_form.submit) }} -
    \ No newline at end of file diff --git a/flask_security/templates/security/registrations/edit.html b/flask_security/templates/security/edit_user.html similarity index 62% rename from flask_security/templates/security/registrations/edit.html rename to flask_security/templates/security/edit_user.html index 0d5c6372..7169a2c0 100644 --- a/flask_security/templates/security/registrations/edit.html +++ b/flask_security/templates/security/edit_user.html @@ -1,7 +1,7 @@ -{% from "security/macros.html" import render_field_with_errors, render_field %} -{% include "security/messages.html" %} +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %}

    Edit Account

    -
    + {{ edit_user_form.hidden_tag() }} {{ render_field_with_errors(edit_user_form.email) }} {{ render_field_with_errors(edit_user_form.password) }} diff --git a/flask_security/templates/security/forgot_password.html b/flask_security/templates/security/forgot_password.html new file mode 100644 index 00000000..27a0d546 --- /dev/null +++ b/flask_security/templates/security/forgot_password.html @@ -0,0 +1,8 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %} +

    Send reset password instructions

    + + {{ forgot_password_form.hidden_tag() }} + {{ render_field_with_errors(forgot_password_form.email) }} + {{ render_field(forgot_password_form.submit) }} +
    \ No newline at end of file diff --git a/flask_security/templates/security/logins/new.html b/flask_security/templates/security/login.html similarity index 57% rename from flask_security/templates/security/logins/new.html rename to flask_security/templates/security/login.html index a81d1fe1..9d9c26fb 100644 --- a/flask_security/templates/security/logins/new.html +++ b/flask_security/templates/security/login.html @@ -1,7 +1,7 @@ -{% from "security/macros.html" import render_field_with_errors, render_field %} -{% include "security/messages.html" %} +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %}

    Login

    -
    + {{ login_form.hidden_tag() }} {{ render_field_with_errors(login_form.email) }} {{ render_field_with_errors(login_form.password) }} diff --git a/flask_security/templates/security/logins/passwordless.html b/flask_security/templates/security/logins/passwordless.html deleted file mode 100644 index d0f353e6..00000000 --- a/flask_security/templates/security/logins/passwordless.html +++ /dev/null @@ -1,9 +0,0 @@ -{% from "security/macros.html" import render_field_with_errors, render_field %} -{% include "security/messages.html" %} -

    Login

    - - {{ login_form.hidden_tag() }} - {{ render_field_with_errors(login_form.email) }} - {{ render_field(login_form.next) }} - {{ render_field(login_form.submit) }} -
    \ No newline at end of file diff --git a/flask_security/templates/security/passwords/new.html b/flask_security/templates/security/passwords/new.html deleted file mode 100644 index 31c4f1f1..00000000 --- a/flask_security/templates/security/passwords/new.html +++ /dev/null @@ -1,8 +0,0 @@ -{% from "security/macros.html" import render_field_with_errors, render_field %} -{% include "security/messages.html" %} -

    Send reset password instructions

    -
    - {{ forgot_password_form.hidden_tag() }} - {{ render_field_with_errors(forgot_password_form.email) }} - {{ render_field(forgot_password_form.submit) }} -
    \ No newline at end of file diff --git a/flask_security/templates/security/registrations/new.html b/flask_security/templates/security/register_user.html similarity index 58% rename from flask_security/templates/security/registrations/new.html rename to flask_security/templates/security/register_user.html index b9f9773c..3219472f 100644 --- a/flask_security/templates/security/registrations/new.html +++ b/flask_security/templates/security/register_user.html @@ -1,7 +1,7 @@ -{% from "security/macros.html" import render_field_with_errors, render_field %} -{% include "security/messages.html" %} +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %}

    Register

    -
    + {{ register_user_form.hidden_tag() }} {{ render_field_with_errors(register_user_form.email) }} {{ render_field_with_errors(register_user_form.password) }} diff --git a/flask_security/templates/security/passwords/edit.html b/flask_security/templates/security/reset_password.html similarity index 50% rename from flask_security/templates/security/passwords/edit.html rename to flask_security/templates/security/reset_password.html index 4a505bec..ca573c85 100644 --- a/flask_security/templates/security/passwords/edit.html +++ b/flask_security/templates/security/reset_password.html @@ -1,7 +1,7 @@ -{% from "security/macros.html" import render_field_with_errors, render_field %} -{% include "security/messages.html" %} +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %}

    Change password

    - + {{ reset_password_form.hidden_tag() }} {{ render_field_with_errors(reset_password_form.password) }} {{ render_field_with_errors(reset_password_form.password_confirm) }} diff --git a/flask_security/templates/security/send_confirmation.html b/flask_security/templates/security/send_confirmation.html new file mode 100644 index 00000000..3c905238 --- /dev/null +++ b/flask_security/templates/security/send_confirmation.html @@ -0,0 +1,8 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %} +

    Resend confirmation instructions

    + + {{ reset_confirmation_form.hidden_tag() }} + {{ render_field_with_errors(reset_confirmation_form.email) }} + {{ render_field(reset_confirmation_form.submit) }} +
    \ No newline at end of file diff --git a/flask_security/templates/security/send_login.html b/flask_security/templates/security/send_login.html new file mode 100644 index 00000000..7a3ea316 --- /dev/null +++ b/flask_security/templates/security/send_login.html @@ -0,0 +1,9 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %} +

    Login

    +
    + {{ login_form.hidden_tag() }} + {{ render_field_with_errors(login_form.email) }} + {{ render_field(login_form.next) }} + {{ render_field(login_form.submit) }} +
    \ No newline at end of file diff --git a/flask_security/utils.py b/flask_security/utils.py index 2680ec57..42a28c7e 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -115,6 +115,21 @@ def get_url(endpoint_or_url): return endpoint_or_url +def url_for_security(endpoint, **values): + """Return a URL for the security blueprint + + :param endpoint: the endpoint of the URL (name of the function) + :param values: the variable arguments of the URL rule + :param _external: if set to `True`, an absolute URL is generated. Server + address can be changed via `SERVER_NAME` configuration variable which + defaults to `localhost`. + :param _anchor: if provided this is added as anchor to the URL. + :param _method: if provided this explicitly specifies an HTTP method. + """ + endpoint = '%s.%s' % (_security.blueprint_name, endpoint) + return url_for(endpoint, **values) + + def get_post_login_redirect(): """Returns the URL to redirect to after a user logs in successfully.""" return (get_url(request.args.get('next')) or diff --git a/flask_security/views.py b/flask_security/views.py index ee3b66d0..d7f6e862 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -128,7 +128,7 @@ def register_user(): return redirect(_security.post_register_view or _security.post_login_view) - return render_template('security/registrations/new.html', + return render_template('security/register_user.html', register_user_form=form) @@ -143,7 +143,7 @@ def send_login(): else: do_flash(*get_message('DISABLED_ACCOUNT')) - return render_template('security/logins/passwordless.html', login_form=form) + return render_template('security/send_login.html', login_form=form) def token_login(token): @@ -182,7 +182,7 @@ def send_confirmation(): do_flash(msg, cat) - return render_template('security/confirmations/new.html', + return render_template('security/send_confirmation.html', reset_confirmation_form=form) @@ -231,7 +231,7 @@ def forgot_password(): for key, value in form.errors.items(): do_flash(value[0], 'error') - return render_template('security/passwords/new.html', + return render_template('security/forgot_password.html', forgot_password_form=form) @@ -267,7 +267,7 @@ def reset_password(token): do_flash(msg, cat) - return render_template('security/passwords/edit.html', + return render_template('security/reset_password.html', reset_password_form=form, password_reset_token=token) From 1d378a682782e5f2f9c652f3feb131d9385a55ca Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 16 Aug 2012 15:18:49 -0400 Subject: [PATCH 146/234] Add login to security blueprint --- example/app.py | 11 ----------- example/templates/_nav.html | 2 +- flask_security/core.py | 1 + flask_security/templates/security/login.html | 2 +- flask_security/views.py | 9 +++++++++ tests/functional_tests.py | 2 +- 6 files changed, 13 insertions(+), 14 deletions(-) diff --git a/example/app.py b/example/app.py index 0bf4b3f0..f67baf19 100644 --- a/example/app.py +++ b/example/app.py @@ -56,17 +56,6 @@ def create_app(auth_config): def index(): return render_template('index.html', content='Home Page') - @app.route('/login') - def login(): - if app.config['SECURITY_PASSWORDLESS']: - form = PasswordlessLoginForm() - template = 'passwordless_login' - else: - form = LoginForm() - template = 'login' - - return render_template(template + '.html', content='Login Page', form=form) - @app.route('/custom_login') def custom_login(): return render_template('login.html', content='Custom Login Page', form=LoginForm()) diff --git a/example/templates/_nav.html b/example/templates/_nav.html index 7dd13473..4c71709c 100644 --- a/example/templates/_nav.html +++ b/example/templates/_nav.html @@ -14,7 +14,7 @@ {%- if current_user.is_authenticated() -%} Log out {%- else -%} - Log in + Log in {%- endif -%}
  • \ No newline at end of file diff --git a/flask_security/core.py b/flask_security/core.py index b810ac19..07986efa 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -37,6 +37,7 @@ 'PASSWORD_SALT': None, 'PASSWORD_HMAC': False, 'AUTH_URL': '/auth', + 'LOGIN_URL': '/login', 'LOGOUT_URL': '/logout', 'REGISTER_URL': '/register', 'RESET_URL': '/reset', diff --git a/flask_security/templates/security/login.html b/flask_security/templates/security/login.html index 9d9c26fb..2e171cd9 100644 --- a/flask_security/templates/security/login.html +++ b/flask_security/templates/security/login.html @@ -1,7 +1,7 @@ {% from "security/_macros.html" import render_field_with_errors, render_field %} {% include "security/_messages.html" %}

    Login

    -
    + {{ login_form.hidden_tag() }} {{ render_field_with_errors(login_form.email) }} {{ render_field_with_errors(login_form.password) }} diff --git a/flask_security/views.py b/flask_security/views.py index d7f6e862..9fe14cd4 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -93,6 +93,12 @@ def authenticate(): return redirect(request.referrer or _security.login_manager.login_view) +def login(): + form = PasswordlessLoginForm() if _security.passwordless else LoginForm() + template = 'send_login' if _security.passwordless else 'login' + return render_template('security/%s.html' % template, login_form=form) + + def logout(): """View function which handles a logout request.""" @@ -288,6 +294,9 @@ def create_blueprint(app, name, import_name, **kwargs): methods=['POST'], endpoint='authenticate')(authenticate) + bp.route(config_value('LOGIN_URL', app=app), + endpoint='login')(login) + bp.route(config_value('LOGOUT_URL', app=app), endpoint='logout')(login_required(logout)) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index d96b8479..80df7fd9 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -36,7 +36,7 @@ def test_instance(self): def test_login_view(self): r = self._get('/login') - self.assertIn('Login Page', r.data) + self.assertIn('

    Login

    ', r.data) def test_authenticate(self): r = self.authenticate() From 1d8b2f83423a9d5aeec704be960723442f30e4f0 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 16 Aug 2012 17:25:24 -0400 Subject: [PATCH 147/234] Change urls/views to be (subjectively) simpler --- example/app.py | 4 --- example/templates/passwordless_login.html | 9 ------ flask_security/core.py | 4 +-- .../templates/security/edit_user.html | 11 -------- .../{register_user.html => register.html} | 0 flask_security/utils.py | 2 -- flask_security/views.py | 28 ++++++++++--------- tests/__init__.py | 3 +- tests/functional_tests.py | 4 +-- 9 files changed, 21 insertions(+), 44 deletions(-) delete mode 100644 example/templates/passwordless_login.html delete mode 100644 flask_security/templates/security/edit_user.html rename flask_security/templates/security/{register_user.html => register.html} (100%) diff --git a/example/app.py b/example/app.py index f67baf19..9d5d2853 100644 --- a/example/app.py +++ b/example/app.py @@ -56,10 +56,6 @@ def create_app(auth_config): def index(): return render_template('index.html', content='Home Page') - @app.route('/custom_login') - def custom_login(): - return render_template('login.html', content='Custom Login Page', form=LoginForm()) - @app.route('/profile') @login_required def profile(): diff --git a/example/templates/passwordless_login.html b/example/templates/passwordless_login.html deleted file mode 100644 index 18279828..00000000 --- a/example/templates/passwordless_login.html +++ /dev/null @@ -1,9 +0,0 @@ -{% include "_messages.html" %} -{% include "_nav.html" %} - - {{ form.hidden_tag() }} - {{ form.email.label }} {{ form.email }}
    - {{ form.next }} - {{ form.submit }} - -

    {{ content }}

    diff --git a/flask_security/core.py b/flask_security/core.py index 07986efa..903ebf04 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -43,11 +43,11 @@ 'RESET_URL': '/reset', 'CONFIRM_URL': '/confirm', 'LOGIN_VIEW': '/login', - 'CONFIRM_ERROR_VIEW': '/confirm', 'POST_LOGIN_VIEW': '/', 'POST_LOGOUT_VIEW': '/', 'POST_FORGOT_VIEW': '/', 'RESET_PASSWORD_ERROR_VIEW': '/', + 'CONFIRM_ERROR_VIEW': None, 'POST_REGISTER_VIEW': None, 'POST_CONFIRM_VIEW': None, 'POST_RESET_VIEW': None, @@ -129,7 +129,7 @@ def _on_identity_loaded(sender, identity): def _get_login_manager(app): lm = LoginManager() lm.anonymous_user = AnonymousUser - lm.login_view = cv('LOGIN_VIEW', app=app) + lm.login_view = '%s.login' % cv('BLUEPRINT_NAME', app=app) lm.user_loader(_user_loader) lm.token_loader(_token_loader) lm.init_app(app) diff --git a/flask_security/templates/security/edit_user.html b/flask_security/templates/security/edit_user.html deleted file mode 100644 index 7169a2c0..00000000 --- a/flask_security/templates/security/edit_user.html +++ /dev/null @@ -1,11 +0,0 @@ -{% from "security/_macros.html" import render_field_with_errors, render_field %} -{% include "security/_messages.html" %} -

    Edit Account

    -
    - {{ edit_user_form.hidden_tag() }} - {{ render_field_with_errors(edit_user_form.email) }} - {{ render_field_with_errors(edit_user_form.password) }} - {{ render_field_with_errors(edit_user_form.password_confirm) }} - {{ render_field_with_errors(edit_user_form.current_password) }} - {{ render_field(edit_user_form.submit) }} -
    \ No newline at end of file diff --git a/flask_security/templates/security/register_user.html b/flask_security/templates/security/register.html similarity index 100% rename from flask_security/templates/security/register_user.html rename to flask_security/templates/security/register.html diff --git a/flask_security/utils.py b/flask_security/utils.py index 42a28c7e..07636eb9 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -145,8 +145,6 @@ def find_redirect(key): result = (get_url(session.pop(key.lower(), None)) or get_url(current_app.config[key.upper()] or None) or '/') - session.pop(key.lower(), None) - return result diff --git a/flask_security/views.py b/flask_security/views.py index 9fe14cd4..63c9453e 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -26,7 +26,7 @@ reset_password_reset_token from flask_security.signals import user_registered from flask_security.utils import get_url, get_post_login_redirect, do_flash, \ - get_message, config_value, login_user, logout_user + get_message, config_value, login_user, logout_user, url_for_security # Convenient references @@ -90,7 +90,7 @@ def authenticate(): do_flash(msg, 'error') - return redirect(request.referrer or _security.login_manager.login_view) + return redirect(request.referrer or url_for_security('login')) def login(): @@ -107,7 +107,7 @@ def logout(): _logger.debug('User logged out') return redirect(request.args.get('next', None) or - _security.post_logout_view) + get_url(_security.post_logout_view)) def register_user(): @@ -131,8 +131,8 @@ def register_user(): if not _security.confirmable or _security.login_without_confirmation: login_user(u) - return redirect(_security.post_register_view or - _security.post_login_view) + return redirect(get_url(_security.post_register_view) or + get_url(_security.post_login_view)) return render_template('security/register_user.html', register_user_form=form) @@ -154,7 +154,7 @@ def send_login(): def token_login(token): if current_user.is_authenticated(): - return redirect(_security.post_login_view) + return redirect(get_url(_security.post_login_view)) try: user, next = login_by_token(token) @@ -167,11 +167,11 @@ def token_login(token): do_flash(msg, cat) - return redirect(request.referrer or _security.login_manager.login_view) + return redirect(request.referrer or url_for_security('login')) do_flash(*get_message('PASSWORDLESS_LOGIN_SUCCESSFUL')) - return redirect(next or _security.post_login_view) + return redirect(next or get_url(_security.post_login_view)) def send_confirmation(): @@ -208,11 +208,13 @@ def confirm_email(token): do_flash(msg, cat) - return redirect(get_url(_security.confirm_error_view)) + return redirect(get_url(_security.confirm_error_view) or + url_for_security('send_confirmation')) do_flash(*get_message('EMAIL_CONFIRMED')) - return redirect(_security.post_confirm_view or _security.post_login_view) + return redirect(get_url(_security.post_confirm_view) or + get_url(_security.post_login_view)) def forgot_password(): @@ -231,7 +233,7 @@ def forgot_password(): do_flash(msg, cat) - return redirect(_security.post_forgot_view) + return redirect(get_url(_security.post_forgot_view)) else: for key, value in form.errors.items(): @@ -256,8 +258,8 @@ def reset_password(token): login_user(user) - return redirect(_security.post_reset_view or - _security.post_login_view) + return redirect(get_url(_security.post_reset_view) or + get_url(_security.post_login_view)) except ResetPasswordError, e: msg, cat = str(e), 'error' diff --git a/tests/__init__.py b/tests/__init__.py index 3c005c4b..57a6cb76 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -35,7 +35,8 @@ def register(self, email, password='password'): def authenticate(self, email="matt@lp.com", password="password", endpoint=None, **kwargs): data = dict(email=email, password=password, remember='y') - return self._post(endpoint or '/auth', data=data, **kwargs) + r = self._post(endpoint or '/auth', data=data, **kwargs) + return r def json_authenticate(self, email="matt@lp.com", password="password", endpoint=None): data = """ diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 80df7fd9..f14d7306 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -207,7 +207,7 @@ class ConfiguredSecurityTests(SecurityTest): 'SECURITY_REGISTERABLE': True, 'SECURITY_AUTH_URL': '/custom_auth', 'SECURITY_LOGOUT_URL': '/custom_logout', - 'SECURITY_LOGIN_VIEW': '/custom_login', + 'SECURITY_LOGIN_URL': '/custom_login', 'SECURITY_POST_LOGIN_VIEW': '/post_login', 'SECURITY_POST_LOGOUT_VIEW': '/post_logout', 'SECURITY_POST_REGISTER_VIEW': '/post_register', @@ -217,7 +217,7 @@ class ConfiguredSecurityTests(SecurityTest): def test_login_view(self): r = self._get('/custom_login') - self.assertIn("Custom Login Page", r.data) + self.assertIn("

    Login

    ", r.data) def test_authenticate(self): r = self.authenticate(endpoint="/custom_auth") From 81ba459fc3b8ea79b3c6a01721cf30013d48dd31 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 16 Aug 2012 17:27:04 -0400 Subject: [PATCH 148/234] Change function name --- flask_security/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flask_security/views.py b/flask_security/views.py index 63c9453e..28ea6987 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -110,7 +110,7 @@ def logout(): get_url(_security.post_logout_view)) -def register_user(): +def register(): """View function which handles a registration request.""" form = RegisterForm(csrf_enabled=not app.testing) @@ -134,7 +134,7 @@ def register_user(): return redirect(get_url(_security.post_register_view) or get_url(_security.post_login_view)) - return render_template('security/register_user.html', + return render_template('security/register.html', register_user_form=form) @@ -305,7 +305,7 @@ def create_blueprint(app, name, import_name, **kwargs): if config_value('REGISTERABLE', app=app): bp.route(config_value('REGISTER_URL', app=app), methods=['GET', 'POST'], - endpoint='register')(register_user) + endpoint='register')(register) if config_value('RECOVERABLE', app=app): bp.route(config_value('RESET_URL', app=app), From df08355b1de3c07487507ed5a1df1e42f72e7c53 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 16 Aug 2012 17:28:50 -0400 Subject: [PATCH 149/234] Fix template --- flask_security/templates/security/login.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/templates/security/login.html b/flask_security/templates/security/login.html index 2e171cd9..9d9c26fb 100644 --- a/flask_security/templates/security/login.html +++ b/flask_security/templates/security/login.html @@ -1,7 +1,7 @@ {% from "security/_macros.html" import render_field_with_errors, render_field %} {% include "security/_messages.html" %}

    Login

    -
    + {{ login_form.hidden_tag() }} {{ render_field_with_errors(login_form.email) }} {{ render_field_with_errors(login_form.password) }} From 8b139890b42dc502c89d8b43df95fbc9d648fa66 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 16 Aug 2012 17:53:41 -0400 Subject: [PATCH 150/234] Add useful decorator for ensuring anonymous users on particular endpoints --- flask_security/core.py | 2 +- flask_security/recoverable.py | 6 ------ flask_security/utils.py | 17 ++++++++++++++--- flask_security/views.py | 18 +++++++++++++----- tests/functional_tests.py | 26 +++++++++++++------------- 5 files changed, 41 insertions(+), 28 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 903ebf04..501c560c 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -83,7 +83,7 @@ 'PASSWORD_RESET_EXPIRED': ('You did not reset your password within %(within)s. New instructions have been sent to %(email)s.', 'error'), 'INVALID_RESET_PASSWORD_TOKEN': ('Invalid reset password token.', 'error'), 'CONFIRMATION_REQUIRED': ('Email requires confirmation.', 'error'), - 'CONFIRMATION_REQUEST': ('A new confirmation code has been sent to %(email)s.', 'info'), + 'CONFIRMATION_REQUEST': ('Confirmation instructions have been sent to %(email)s.', 'info'), 'CONFIRMATION_EXPIRED': ('You did not confirm your email within %(within)s. New instructions to confirm your email have been sent to %(email)s.', 'error'), 'LOGIN_EXPIRED': ('You did not login within %(within)s. New instructions to login to your account have been sent to %(email)s.', 'error'), 'LOGIN_EMAIL_SENT': ('Instructions to log in to your account have been sent to %(email)s.', 'success'), diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index c6125a3a..25e6d63f 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -80,9 +80,6 @@ def reset_by_token(token, password): data = serializer.loads(token, max_age=max_age) user = _datastore.find_user(id=data[0]) - if md5(user.password) != data[1]: - raise UserNotFoundError() - user.password = encrypt_password(password, salt=_security.password_salt, use_hmac=_security.password_hmac) @@ -103,9 +100,6 @@ def reset_by_token(token, password): except BadSignature: raise ResetPasswordError(get_message('INVALID_RESET_PASSWORD_TOKEN')) - except UserNotFoundError: - raise ResetPasswordError(get_message('INVALID_RESET_PASSWORD_TOKEN')) - def reset_password_reset_token(user): """Resets the specified user's reset password token and sends the user diff --git a/flask_security/utils.py b/flask_security/utils.py index 07636eb9..412d2acd 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -12,16 +12,18 @@ import base64 import hashlib import hmac -import os from contextlib import contextmanager from datetime import datetime, timedelta +from functools import wraps -from flask import url_for, flash, current_app, request, session, render_template -from flask.ext.login import make_secure_token, login_user as _login_user, \ +from flask import url_for, flash, current_app, request, session, redirect, \ + render_template +from flask.ext.login import login_user as _login_user, \ logout_user as _logout_user from flask.ext.principal import Identity, AnonymousIdentity, identity_changed from werkzeug.local import LocalProxy +from .core import current_user from .signals import user_registered, password_reset_requested, \ login_instructions_sent @@ -36,6 +38,15 @@ _logger = LocalProxy(lambda: current_app.logger) +def anonymous_user_required(f): + @wraps(f) + def wrapper(*args, **kwargs): + if current_user.is_authenticated(): + return redirect(get_url(_security.post_login_view)) + return f(*args, **kwargs) + return wrapper + + def login_user(user, remember=True): """Performs the login and sends the appropriate signal.""" diff --git a/flask_security/views.py b/flask_security/views.py index 28ea6987..555a15ab 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -26,7 +26,8 @@ reset_password_reset_token from flask_security.signals import user_registered from flask_security.utils import get_url, get_post_login_redirect, do_flash, \ - get_message, config_value, login_user, logout_user, url_for_security + get_message, config_value, login_user, logout_user, url_for_security, \ + anonymous_user_required # Convenient references @@ -93,12 +94,14 @@ def authenticate(): return redirect(request.referrer or url_for_security('login')) +@anonymous_user_required def login(): form = PasswordlessLoginForm() if _security.passwordless else LoginForm() template = 'send_login' if _security.passwordless else 'login' return render_template('security/%s.html' % template, login_form=form) +@login_required def logout(): """View function which handles a logout request.""" @@ -110,6 +113,7 @@ def logout(): get_url(_security.post_logout_view)) +@anonymous_user_required def register(): """View function which handles a registration request.""" @@ -138,6 +142,7 @@ def register(): register_user_form=form) +@anonymous_user_required def send_login(): form = PasswordlessLoginForm() @@ -152,10 +157,8 @@ def send_login(): return render_template('security/send_login.html', login_form=form) +@anonymous_user_required def token_login(token): - if current_user.is_authenticated(): - return redirect(get_url(_security.post_login_view)) - try: user, next = login_by_token(token) @@ -174,6 +177,7 @@ def token_login(token): return redirect(next or get_url(_security.post_login_view)) +@anonymous_user_required def send_confirmation(): form = ResendConfirmationForm(csrf_enabled=not app.testing) @@ -213,10 +217,13 @@ def confirm_email(token): do_flash(*get_message('EMAIL_CONFIRMED')) + login_user(user, True) + return redirect(get_url(_security.post_confirm_view) or get_url(_security.post_login_view)) +@anonymous_user_required def forgot_password(): """View function that handles a forgotten password request.""" @@ -243,6 +250,7 @@ def forgot_password(): forgot_password_form=form) +@anonymous_user_required def reset_password(token): """View function that handles a reset password request.""" @@ -300,7 +308,7 @@ def create_blueprint(app, name, import_name, **kwargs): endpoint='login')(login) bp.route(config_value('LOGOUT_URL', app=app), - endpoint='logout')(login_required(logout)) + endpoint='logout')(logout) if config_value('REGISTERABLE', app=app): bp.route(config_value('REGISTER_URL', app=app), diff --git a/tests/functional_tests.py b/tests/functional_tests.py index f14d7306..24914b32 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -320,7 +320,7 @@ def test_resend_confirmation(self): e = 'dude@lp.com' self.register(e) r = self._post('/confirm', data={'email': e}) - self.assertIn('A new confirmation code has been sent to dude@lp.com', r.data) + self.assertIn(self.get_message('CONFIRMATION_REQUEST', email=e), r.data) class ExpiredConfirmationTest(SecurityTest): @@ -407,20 +407,20 @@ def test_reset_password_with_invalid_token(self): self.assertIn(self.get_message('INVALID_RESET_PASSWORD_TOKEN'), r.data) - def test_reset_password_twice_flashes_invalid_token_msg(self): - with capture_reset_password_requests() as requests: - self.client.post('/reset', data=dict(email='joe@lp.com')) - t = requests[0]['token'] + # def test_reset_password_twice_flashes_invalid_token_msg(self): + # with capture_reset_password_requests() as requests: + # self.client.post('/reset', data=dict(email='joe@lp.com')) + # t = requests[0]['token'] - data = { - 'password': 'newpassword', - 'password_confirm': 'newpassword' - } + # data = { + # 'password': 'newpassword', + # 'password_confirm': 'newpassword' + # } - url = '/reset/' + t - r = self.client.post(url, data=data, follow_redirects=True) - r = self.client.post(url, data=data, follow_redirects=True) - self.assertIn(self.get_message('INVALID_RESET_PASSWORD_TOKEN'), r.data) + # url = '/reset/' + t + # r = self.client.post(url, data=data, follow_redirects=True) + # r = self.client.post(url, data=data, follow_redirects=True) + # self.assertIn(self.get_message('INVALID_RESET_PASSWORD_TOKEN'), r.data) class ExpiredResetPasswordTest(SecurityTest): From eeace79ef95a2473936b3051e4b7ab7a3498f58c Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 16 Aug 2012 18:03:47 -0400 Subject: [PATCH 151/234] Update default login template and add security state to app context processory --- example/templates/login.html | 11 ----------- flask_security/core.py | 5 +++-- .../templates/security/forgot_password.html | 2 +- flask_security/templates/security/login.html | 11 ++++++++++- 4 files changed, 14 insertions(+), 15 deletions(-) delete mode 100644 example/templates/login.html diff --git a/example/templates/login.html b/example/templates/login.html deleted file mode 100644 index 231bb564..00000000 --- a/example/templates/login.html +++ /dev/null @@ -1,11 +0,0 @@ -{% include "_messages.html" %} -{% include "_nav.html" %} - - {{ form.hidden_tag() }} - {{ form.email.label }} {{ form.email }}
    - {{ form.password.label }} {{ form.password }}
    - {{ form.remember.label }} {{ form.remember }}
    - {{ form.next }} - {{ form.submit }} - -

    {{ content }}

    diff --git a/flask_security/core.py b/flask_security/core.py index 501c560c..64cdc0ca 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -256,12 +256,13 @@ def init_app(self, app, datastore=None, register_blueprint=True): template_folder='templates') app.register_blueprint(bp) - app.context_processor(lambda: dict(url_for_security=url_for_security)) - state = self._get_state(app, datastore or self.datastore) app.extensions['security'] = state + app.context_processor(lambda: dict(url_for_security=url_for_security, + security=state)) + return state def _get_state(self, app, datastore): diff --git a/flask_security/templates/security/forgot_password.html b/flask_security/templates/security/forgot_password.html index 27a0d546..be818dc1 100644 --- a/flask_security/templates/security/forgot_password.html +++ b/flask_security/templates/security/forgot_password.html @@ -1,6 +1,6 @@ {% from "security/_macros.html" import render_field_with_errors, render_field %} {% include "security/_messages.html" %} -

    Send reset password instructions

    +

    Send password reset instructions

    {{ forgot_password_form.hidden_tag() }} {{ render_field_with_errors(forgot_password_form.email) }} diff --git a/flask_security/templates/security/login.html b/flask_security/templates/security/login.html index 9d9c26fb..dd746013 100644 --- a/flask_security/templates/security/login.html +++ b/flask_security/templates/security/login.html @@ -8,4 +8,13 @@

    Login

    {{ render_field_with_errors(login_form.remember) }} {{ render_field(login_form.next) }} {{ render_field(login_form.submit) }} -
    \ No newline at end of file + +

    Help

    +

    + {% if security.recoverable %} + Forgot password
    + {% endif %} + {% if security.confirmabled %} + Confirm account + {% endif %} +

    \ No newline at end of file From 592003ecb6b2c5f4db97a1919b87cd38d6b77a8b Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 16 Aug 2012 18:04:34 -0400 Subject: [PATCH 152/234] Fix error in template --- flask_security/templates/security/login.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/templates/security/login.html b/flask_security/templates/security/login.html index dd746013..9f2b39a7 100644 --- a/flask_security/templates/security/login.html +++ b/flask_security/templates/security/login.html @@ -14,7 +14,7 @@

    Help

    {% if security.recoverable %} Forgot password
    {% endif %} - {% if security.confirmabled %} + {% if security.confirmable %} Confirm account {% endif %}

    \ No newline at end of file From 009671090f996dc18ec1b7407b70e3648582b4c0 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 16 Aug 2012 18:20:42 -0400 Subject: [PATCH 153/234] Clean up and bug improvements --- flask_security/confirmable.py | 4 +-- flask_security/core.py | 2 +- flask_security/decorators.py | 2 +- flask_security/recoverable.py | 2 +- flask_security/views.py | 49 +++++++++++++++++++++-------------- 5 files changed, 35 insertions(+), 24 deletions(-) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index c2c1d292..85b2e5d3 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -73,7 +73,7 @@ def confirm_by_token(token): user = _datastore.find_user(id=data[0]) if user.confirmed_at: - raise ConfirmationError(get_message('ALREADY_CONFIRMED')) + raise ConfirmationError(get_message('ALREADY_CONFIRMED')[0]) user.confirmed_at = datetime.utcnow() _datastore._save_model(user) @@ -91,7 +91,7 @@ def confirm_by_token(token): raise ConfirmationError(msg, user=user) except BadSignature: - raise ConfirmationError(get_message('INVALID_CONFIRMATION_TOKEN')) + raise ConfirmationError(get_message('INVALID_CONFIRMATION_TOKEN')[0]) def reset_confirmation_token(user): diff --git a/flask_security/core.py b/flask_security/core.py index 64cdc0ca..735b0dd6 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -342,7 +342,7 @@ def do_authenticate(self, username_or_email, password): raise exceptions.BadCredentialsError('Specified user does not exist.') if requires_confirmation(user): - raise exceptions.BadCredentialsError('Email requires confirmation.') + raise exceptions.ConfirmationError('Email requires confirmation.') # compare passwords if verify_password(password, user.password, diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 5b766cae..9b3d7e3c 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -43,7 +43,7 @@ def _get_unauthorized_response(text=None, headers=None): def _get_unauthorized_view(): cv = utils.get_url(utils.config_value('UNAUTHORIZED_VIEW')) - utils.do_flash(utils.get_message('UNAUTHORIZED')) + utils.do_flash(*utils.get_message('UNAUTHORIZED')) return redirect(cv or request.referrer or '/') diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 25e6d63f..15e34b6f 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -98,7 +98,7 @@ def reset_by_token(token, password): user=_datastore.find_user(id=data[0])) except BadSignature: - raise ResetPasswordError(get_message('INVALID_RESET_PASSWORD_TOKEN')) + raise ResetPasswordError(get_message('INVALID_RESET_PASSWORD_TOKEN')[0]) def reset_password_reset_token(user): diff --git a/flask_security/views.py b/flask_security/views.py index 555a15ab..f55f3b0d 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -67,6 +67,7 @@ def _json_auth_error(msg): def authenticate(): """View function which handles an authentication request.""" + confirm_url = None form_data = MultiDict(request.json) if request.json else request.form form = LoginForm(form_data) @@ -81,6 +82,10 @@ def authenticate(): raise BadCredentialsError(get_message('DISABLED_ACCOUNT')[0]) + except ConfirmationError, e: + msg = str(e) + confirm_url = url_for_security('send_confirmation') + except BadCredentialsError, e: msg = str(e) @@ -91,11 +96,15 @@ def authenticate(): do_flash(msg, 'error') - return redirect(request.referrer or url_for_security('login')) + return redirect(request.referrer or + confirm_url or + url_for_security('login')) @anonymous_user_required def login(): + """View function for login view""" + form = PasswordlessLoginForm() if _security.passwordless else LoginForm() template = 'send_login' if _security.passwordless else 'login' return render_template('security/%s.html' % template, login_form=form) @@ -144,6 +153,7 @@ def register(): @anonymous_user_required def send_login(): + """View function that sends login instructions for passwordless login""" form = PasswordlessLoginForm() user = _datastore.find_user(**form.to_dict()) @@ -159,16 +169,16 @@ def send_login(): @anonymous_user_required def token_login(token): + """View function that handles passwordless login via a token""" + try: user, next = login_by_token(token) except PasswordlessLoginError, e: - msg, cat = str(e), 'error' - if e.user: send_login_instructions(e.user, e.next) - do_flash(msg, cat) + do_flash(str(e), 'error') return redirect(request.referrer or url_for_security('login')) @@ -179,6 +189,8 @@ def token_login(token): @anonymous_user_required def send_confirmation(): + """View function which sends confirmation instructions.""" + form = ResendConfirmationForm(csrf_enabled=not app.testing) if form.validate_on_submit(): @@ -188,9 +200,7 @@ def send_confirmation(): _logger.debug('%s request confirmation instructions' % user) - msg, cat = get_message('CONFIRMATION_REQUEST', email=user.email) - - do_flash(msg, cat) + do_flash(*get_message('CONFIRMATION_REQUEST', email=user.email)) return render_template('security/send_confirmation.html', reset_confirmation_form=form) @@ -198,19 +208,20 @@ def send_confirmation(): def confirm_email(token): """View function which handles a email confirmation request.""" + try: user = confirm_by_token(token) _logger.debug('%s confirmed their email' % user) except ConfirmationError, e: - msg, cat = str(e), 'error' + msg = (str(e), 'error') - _logger.debug('Confirmation error: ' + msg) + _logger.debug('Confirmation error: ' + msg[0]) if e.user: reset_confirmation_token(e.user) - do_flash(msg, cat) + do_flash(*msg) return redirect(get_url(_security.confirm_error_view) or url_for_security('send_confirmation')) @@ -236,9 +247,7 @@ def forgot_password(): _logger.debug('%s requested to reset their password' % user) - msg, cat = get_message('PASSWORD_RESET_REQUEST', email=user.email) - - do_flash(msg, cat) + do_flash(*get_message('PASSWORD_RESET_REQUEST', email=user.email)) return redirect(get_url(_security.post_forgot_view)) @@ -270,18 +279,18 @@ def reset_password(token): get_url(_security.post_login_view)) except ResetPasswordError, e: - msg, cat = str(e), 'error' + msg = (str(e), 'error') - _logger.debug('Password reset error: ' + msg) + _logger.debug('Password reset error: ' + msg[0]) if e.user: reset_password_reset_token(e.user) - msg, cat = get_message('PASSWORD_RESET_EXPIRED', - within=_security.reset_password_within, - email=e.user.email) + msg = get_message('PASSWORD_RESET_EXPIRED', + within=_security.reset_password_within, + email=e.user.email) - do_flash(msg, cat) + do_flash(*msg) return render_template('security/reset_password.html', reset_password_form=form, @@ -289,6 +298,8 @@ def reset_password(token): def create_blueprint(app, name, import_name, **kwargs): + """Creates the security extension blueprint""" + bp = Blueprint(name, import_name, **kwargs) if config_value('PASSWORDLESS', app=app): From 96e11916af2aaac5a4f14d226ece2414299ff2ad Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 16 Aug 2012 18:23:24 -0400 Subject: [PATCH 154/234] Always redirect to login view and not referrer --- flask_security/views.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/flask_security/views.py b/flask_security/views.py index f55f3b0d..fddddea0 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -96,9 +96,7 @@ def authenticate(): do_flash(msg, 'error') - return redirect(request.referrer or - confirm_url or - url_for_security('login')) + return redirect(confirm_url or url_for_security('login')) @anonymous_user_required From 704af1011acc1aa70d582eb7195744dc509e17e1 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 16 Aug 2012 18:31:32 -0400 Subject: [PATCH 155/234] Fix up forms to grab values in certain cases --- flask_security/core.py | 2 +- flask_security/forms.py | 10 ++++++++-- flask_security/views.py | 7 +++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 735b0dd6..794e5fb3 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -342,7 +342,7 @@ def do_authenticate(self, username_or_email, password): raise exceptions.BadCredentialsError('Specified user does not exist.') if requires_confirmation(user): - raise exceptions.ConfirmationError('Email requires confirmation.') + raise exceptions.ConfirmationError('Email requires confirmation.', user) # compare passwords if verify_password(password, user.password, diff --git a/flask_security/forms.py b/flask_security/forms.py index 968fb359..bacf1188 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -51,11 +51,16 @@ class PasswordConfirmFormMixin(): validators=[EqualTo('password', message="Passwords do not match")]) -class ResendConfirmationForm(Form, UserEmailFormMixin): +class SendConfirmationForm(Form, UserEmailFormMixin): """The default forgot password form""" submit = SubmitField("Resend Confirmation Instructions") + def __init__(self, *args, **kwargs): + super(SendConfirmationForm, self).__init__(*args, **kwargs) + if request.method == 'GET': + self.email.data = request.args.get('email', None) + def to_dict(self): return dict(email=self.email.data) @@ -77,7 +82,8 @@ class PasswordlessLoginForm(Form, EmailFormMixin): def __init__(self, *args, **kwargs): super(PasswordlessLoginForm, self).__init__(*args, **kwargs) - self.next.data = request.args.get('next', None) + if request.method == 'GET': + self.next.data = request.args.get('next', None) def to_dict(self): return dict(email=self.email.data) diff --git a/flask_security/views.py b/flask_security/views.py index fddddea0..514fc0d1 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -15,12 +15,11 @@ from werkzeug.local import LocalProxy from flask_security.confirmable import confirm_by_token, reset_confirmation_token -from flask_security.core import current_user from flask_security.decorators import login_required from flask_security.exceptions import ConfirmationError, BadCredentialsError, \ ResetPasswordError, PasswordlessLoginError from flask_security.forms import LoginForm, RegisterForm, ForgotPasswordForm, \ - ResetPasswordForm, ResendConfirmationForm, PasswordlessLoginForm + ResetPasswordForm, SendConfirmationForm, PasswordlessLoginForm from flask_security.passwordless import send_login_instructions, login_by_token from flask_security.recoverable import reset_by_token, \ reset_password_reset_token @@ -84,7 +83,7 @@ def authenticate(): except ConfirmationError, e: msg = str(e) - confirm_url = url_for_security('send_confirmation') + confirm_url = url_for_security('send_confirmation', email=e.user.email) except BadCredentialsError, e: msg = str(e) @@ -189,7 +188,7 @@ def token_login(token): def send_confirmation(): """View function which sends confirmation instructions.""" - form = ResendConfirmationForm(csrf_enabled=not app.testing) + form = SendConfirmationForm(csrf_enabled=not app.testing) if form.validate_on_submit(): user = _datastore.find_user(**form.to_dict()) From d87676027e0c74c0038c8728dcc827b629083c00 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 16 Aug 2012 18:42:37 -0400 Subject: [PATCH 156/234] Fix some redirect rules --- flask_security/core.py | 4 ++-- flask_security/templates/security/_messages.html | 2 +- flask_security/views.py | 7 +++++-- tests/functional_tests.py | 2 ++ 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 794e5fb3..80e66ef3 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -45,12 +45,12 @@ 'LOGIN_VIEW': '/login', 'POST_LOGIN_VIEW': '/', 'POST_LOGOUT_VIEW': '/', - 'POST_FORGOT_VIEW': '/', - 'RESET_PASSWORD_ERROR_VIEW': '/', + 'POST_FORGOT_VIEW': None, 'CONFIRM_ERROR_VIEW': None, 'POST_REGISTER_VIEW': None, 'POST_CONFIRM_VIEW': None, 'POST_RESET_VIEW': None, + 'RESET_PASSWORD_ERROR_VIEW': None, 'UNAUTHORIZED_VIEW': None, 'DEFAULT_ROLES': [], 'CONFIRMABLE': False, diff --git a/flask_security/templates/security/_messages.html b/flask_security/templates/security/_messages.html index 788a4134..179d0636 100644 --- a/flask_security/templates/security/_messages.html +++ b/flask_security/templates/security/_messages.html @@ -1,6 +1,6 @@ {%- with messages = get_flashed_messages(with_categories=true) -%} {% if messages %} -
      +
        {% for category, message in messages %}
      • {{ message }}
      • {% endfor %} diff --git a/flask_security/views.py b/flask_security/views.py index 514fc0d1..81432b21 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -246,8 +246,8 @@ def forgot_password(): do_flash(*get_message('PASSWORD_RESET_REQUEST', email=user.email)) - return redirect(get_url(_security.post_forgot_view)) - + if _security.post_forgot_view: + return redirect(get_url(_security.post_forgot_view)) else: for key, value in form.errors.items(): do_flash(value[0], 'error') @@ -289,6 +289,9 @@ def reset_password(token): do_flash(*msg) + if _security.reset_password_error_view: + return redirect(get_url(_security.reset_password_error_view)) + return render_template('security/reset_password.html', reset_password_form=form, password_reset_token=token) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 24914b32..b913c640 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -370,6 +370,8 @@ class RecoverableTests(SecurityTest): AUTH_CONFIG = { 'SECURITY_RECOVERABLE': True, + 'SECURITY_RESET_PASSWORD_ERROR_VIEW': '/', + 'SECURITY_POST_FORGOT_VIEW': '/' } def test_forgot_post_sends_email(self): From dec858dae98bed40dc62c3662ca16f152935e888 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 16 Aug 2012 18:53:27 -0400 Subject: [PATCH 157/234] Add registration message --- flask_security/core.py | 5 +++-- .../templates/security/email/confirmation_instructions.html | 2 +- .../templates/security/email/confirmation_instructions.txt | 2 +- flask_security/templates/security/reset_password.html | 2 +- flask_security/views.py | 3 +++ 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 80e66ef3..3db091a1 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -76,6 +76,7 @@ #: Default Flask-Security messages _default_messages = { 'UNAUTHORIZED': ('You do not have permission to view this resource.', 'error'), + 'CONFIRM_REGISTRATION': ('Thank you. Confirmation instructions have been sent to %(email)s.', 'success'), 'EMAIL_CONFIRMED': ('Your email has been confirmed. You may now log in.', 'success'), 'ALREADY_CONFIRMED': ('Your email has already been confirmed.', 'info'), 'INVALID_CONFIRMATION_TOKEN': ('Invalid confirmation token.', 'error'), @@ -85,8 +86,8 @@ 'CONFIRMATION_REQUIRED': ('Email requires confirmation.', 'error'), 'CONFIRMATION_REQUEST': ('Confirmation instructions have been sent to %(email)s.', 'info'), 'CONFIRMATION_EXPIRED': ('You did not confirm your email within %(within)s. New instructions to confirm your email have been sent to %(email)s.', 'error'), - 'LOGIN_EXPIRED': ('You did not login within %(within)s. New instructions to login to your account have been sent to %(email)s.', 'error'), - 'LOGIN_EMAIL_SENT': ('Instructions to log in to your account have been sent to %(email)s.', 'success'), + 'LOGIN_EXPIRED': ('You did not login within %(within)s. New instructions to login have been sent to %(email)s.', 'error'), + 'LOGIN_EMAIL_SENT': ('Instructions to login have been sent to %(email)s.', 'success'), 'INVALID_LOGIN_TOKEN': ('Invalid login token.', 'error'), 'DISABLED_ACCOUNT': ('Account is disabled.', 'error'), 'PASSWORDLESS_LOGIN_SUCCESSFUL': ('You have successfuly logged in.', 'success'), diff --git a/flask_security/templates/security/email/confirmation_instructions.html b/flask_security/templates/security/email/confirmation_instructions.html index 3f7c8407..92badd04 100644 --- a/flask_security/templates/security/email/confirmation_instructions.html +++ b/flask_security/templates/security/email/confirmation_instructions.html @@ -1,5 +1,5 @@

        Welcome {{ user.email }}!

        -

        You can confirm your account email through the link below:

        +

        You can confirm your email through the link below:

        Confirm my account

        \ No newline at end of file diff --git a/flask_security/templates/security/email/confirmation_instructions.txt b/flask_security/templates/security/email/confirmation_instructions.txt index c2534603..e6a4a3a8 100644 --- a/flask_security/templates/security/email/confirmation_instructions.txt +++ b/flask_security/templates/security/email/confirmation_instructions.txt @@ -1,5 +1,5 @@ Welcome {{ user.email }}! -You can confirm your account email through the link below: +You can confirm your email through the link below: {{ confirmation_link }} \ No newline at end of file diff --git a/flask_security/templates/security/reset_password.html b/flask_security/templates/security/reset_password.html index ca573c85..b383df0c 100644 --- a/flask_security/templates/security/reset_password.html +++ b/flask_security/templates/security/reset_password.html @@ -1,6 +1,6 @@ {% from "security/_macros.html" import render_field_with_errors, render_field %} {% include "security/_messages.html" %} -

        Change password

        +

        Reset password

        {{ reset_password_form.hidden_tag() }} {{ render_field_with_errors(reset_password_form.password) }} diff --git a/flask_security/views.py b/flask_security/views.py index 81432b21..de03d747 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -141,6 +141,9 @@ def register(): if not _security.confirmable or _security.login_without_confirmation: login_user(u) + if _security.confirmable: + do_flash(*get_message('CONFIRM_REGISTRATION', email=u.email)) + return redirect(get_url(_security.post_register_view) or get_url(_security.post_login_view)) From f00f2eff21ab83c25b3505bee41812c2fada96be Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 16 Aug 2012 18:54:38 -0400 Subject: [PATCH 158/234] Update message --- flask_security/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/core.py b/flask_security/core.py index 3db091a1..b27e7a09 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -77,7 +77,7 @@ _default_messages = { 'UNAUTHORIZED': ('You do not have permission to view this resource.', 'error'), 'CONFIRM_REGISTRATION': ('Thank you. Confirmation instructions have been sent to %(email)s.', 'success'), - 'EMAIL_CONFIRMED': ('Your email has been confirmed. You may now log in.', 'success'), + 'EMAIL_CONFIRMED': ('Thakn you. Your email has been confirmed.', 'success'), 'ALREADY_CONFIRMED': ('Your email has already been confirmed.', 'info'), 'INVALID_CONFIRMATION_TOKEN': ('Invalid confirmation token.', 'error'), 'PASSWORD_RESET_REQUEST': ('Instructions to reset your password have been sent to %(email)s.', 'info'), From 1c31728a26a929419e1b8394aa7ca4ebfedf3dec Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 16 Aug 2012 18:54:45 -0400 Subject: [PATCH 159/234] Update message --- flask_security/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/core.py b/flask_security/core.py index b27e7a09..19dc343e 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -77,7 +77,7 @@ _default_messages = { 'UNAUTHORIZED': ('You do not have permission to view this resource.', 'error'), 'CONFIRM_REGISTRATION': ('Thank you. Confirmation instructions have been sent to %(email)s.', 'success'), - 'EMAIL_CONFIRMED': ('Thakn you. Your email has been confirmed.', 'success'), + 'EMAIL_CONFIRMED': ('Thank you. Your email has been confirmed.', 'success'), 'ALREADY_CONFIRMED': ('Your email has already been confirmed.', 'info'), 'INVALID_CONFIRMATION_TOKEN': ('Invalid confirmation token.', 'error'), 'PASSWORD_RESET_REQUEST': ('Instructions to reset your password have been sent to %(email)s.', 'info'), From adb550a9f244431d2025f716b653717d73c2caa3 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 16 Aug 2012 19:05:42 -0400 Subject: [PATCH 160/234] Improve RegisterUserForm --- flask_security/forms.py | 17 ++++++++++++++++- tests/functional_tests.py | 9 ++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/flask_security/forms.py b/flask_security/forms.py index bacf1188..b021fb63 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -21,6 +21,14 @@ _datastore = LocalProxy(lambda: app.extensions['security'].datastore) +def unique_user_email(form, field): + try: + _datastore.find_user(email=field.data) + raise ValidationError('%s is already associated with an account' % field.data) + except UserNotFoundError: + pass + + def valid_user_email(form, field): try: _datastore.find_user(email=field.data) @@ -41,6 +49,13 @@ class UserEmailFormMixin(): valid_user_email]) +class UniqueEmailFormMixin(): + email = TextField("Email Address", + validators=[Required(message="Email not provided"), + Email(message="Invalid email address"), + unique_user_email]) + + class PasswordFormMixin(): password = PasswordField("Password", validators=[Required(message="Password not provided")]) @@ -102,7 +117,7 @@ def __init__(self, *args, **kwargs): class RegisterForm(Form, - EmailFormMixin, + UniqueEmailFormMixin, PasswordFormMixin, PasswordConfirmFormMixin): """The default register form""" diff --git a/tests/functional_tests.py b/tests/functional_tests.py index b913c640..f8d3ef3a 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -237,9 +237,16 @@ def test_register(self): password='password', password_confirm='password') - r = self.client.post('/register', data=data, follow_redirects=True) + r = self._post('/register', data=data, follow_redirects=True) self.assertIn('Post Register', r.data) + def test_register_existing_email(self): + data = dict(email='matt@lp.com', + password='password', + password_confirm='password') + r = self._post('/register', data=data, follow_redirects=True) + self.assertIn('matt@lp.com is already associated with an account', r.data) + def test_unauthorized(self): self.authenticate("joe@lp.com", endpoint="/custom_auth") r = self._get("/admin", follow_redirects=True) From beff7a246d495cf76cedb9b9f79181d1861e141d Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 17 Aug 2012 11:50:23 -0400 Subject: [PATCH 161/234] Add context processors for security blueprint --- example/app.py | 36 +++++++++++++++ flask_security/core.py | 45 +++++++++++++++++++ .../templates/security/reset_password.html | 2 +- flask_security/utils.py | 6 ++- flask_security/views.py | 18 +++++--- 5 files changed, 99 insertions(+), 8 deletions(-) diff --git a/example/app.py b/example/app.py index 9d5d2853..1d7b596e 100644 --- a/example/app.py +++ b/example/app.py @@ -41,6 +41,38 @@ def populate_data(): create_users() +def add_ctx_processors(app): + s = app.security + + @s.context_processor + def for_all(): + return dict() + + @s.forgot_password_context_processor + def forgot_password(): + return dict() + + @s.login_context_processor + def login(): + return dict() + + @s.register_context_processor + def register(): + return dict() + + @s.reset_password_context_processor + def reset_password(): + return dict() + + @s.send_confirmation_context_processor + def send_confirmation(): + return dict() + + @s.send_login_context_processor + def send_login(): + return dict() + + def create_app(auth_config): app = Flask(__name__) app.debug = True @@ -192,6 +224,8 @@ def before_first_request(): db.create_all() populate_data() + add_ctx_processors(app) + return app @@ -228,6 +262,8 @@ def before_first_request(): Role.drop_collection() populate_data() + add_ctx_processors(app) + return app if __name__ == '__main__': diff --git a/flask_security/core.py b/flask_security/core.py index 19dc343e..b548b293 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -219,6 +219,49 @@ def __init__(self, **kwargs): for key, value in kwargs.items(): setattr(self, key.lower(), value) + def _add_ctx_processor(self, endpoint, fn): + c = self.context_processors + + if endpoint not in c: + c[endpoint] = [] + + if fn not in c[endpoint]: + c[endpoint].append(fn) + + def _run_ctx_processor(self, endpoint): + fns = [] + rv = {} + + for g in ['all', endpoint]: + if g in self.context_processors: + fns += self.context_processors[g] + + for fn in fns: + rv.update(fn()) + + return rv + + def context_processor(self, fn): + self._add_ctx_processor('all', fn) + + def forgot_password_context_processor(self, fn): + self._add_ctx_processor('forgot_password', fn) + + def login_context_processor(self, fn): + self._add_ctx_processor('login', fn) + + def register_context_processor(self, fn): + self._add_ctx_processor('register', fn) + + def reset_password_context_processor(self, fn): + self._add_ctx_processor('reset_password', fn) + + def send_confirmation_context_processor(self, fn): + self._add_ctx_processor('send_confirmation', fn) + + def send_login_context_processor(self, fn): + self._add_ctx_processor('send_login', fn) + class Security(object): """The :class:`Security` class initializes the Flask-Security extension. @@ -286,6 +329,8 @@ def _get_state(self, app, datastore): ('token_auth_serializer', _get_token_auth_serializer(app))]: kwargs[key] = value + kwargs['context_processors'] = {} + kwargs['login_serializer'] = ( _get_login_serializer(app) if kwargs['passwordless'] else None) diff --git a/flask_security/templates/security/reset_password.html b/flask_security/templates/security/reset_password.html index b383df0c..0adcad84 100644 --- a/flask_security/templates/security/reset_password.html +++ b/flask_security/templates/security/reset_password.html @@ -1,7 +1,7 @@ {% from "security/_macros.html" import render_field_with_errors, render_field %} {% include "security/_messages.html" %}

        Reset password

        - + {{ reset_password_form.hidden_tag() }} {{ render_field_with_errors(reset_password_form.password) }} {{ render_field_with_errors(reset_password_form.password_confirm) }} diff --git a/flask_security/utils.py b/flask_security/utils.py index 412d2acd..0ca02453 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -126,6 +126,10 @@ def get_url(endpoint_or_url): return endpoint_or_url +def get_security_endpoint_name(endpoint): + return '%s.%s' % (_security.blueprint_name, endpoint) + + def url_for_security(endpoint, **values): """Return a URL for the security blueprint @@ -137,7 +141,7 @@ def url_for_security(endpoint, **values): :param _anchor: if provided this is added as anchor to the URL. :param _method: if provided this explicitly specifies an HTTP method. """ - endpoint = '%s.%s' % (_security.blueprint_name, endpoint) + endpoint = get_security_endpoint_name(endpoint) return url_for(endpoint, **values) diff --git a/flask_security/views.py b/flask_security/views.py index de03d747..8390dfe1 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -104,7 +104,8 @@ def login(): form = PasswordlessLoginForm() if _security.passwordless else LoginForm() template = 'send_login' if _security.passwordless else 'login' - return render_template('security/%s.html' % template, login_form=form) + return render_template('security/%s.html' % template, login_form=form, + **_security._run_ctx_processor('login')) @login_required @@ -148,7 +149,8 @@ def register(): get_url(_security.post_login_view)) return render_template('security/register.html', - register_user_form=form) + register_user_form=form, + **_security._run_ctx_processor('register')) @anonymous_user_required @@ -164,7 +166,8 @@ def send_login(): else: do_flash(*get_message('DISABLED_ACCOUNT')) - return render_template('security/send_login.html', login_form=form) + return render_template('security/send_login.html', login_form=form, + **_security._run_ctx_processor('send_login')) @anonymous_user_required @@ -203,7 +206,8 @@ def send_confirmation(): do_flash(*get_message('CONFIRMATION_REQUEST', email=user.email)) return render_template('security/send_confirmation.html', - reset_confirmation_form=form) + reset_confirmation_form=form, + **_security._run_ctx_processor('send_confirmation')) def confirm_email(token): @@ -256,7 +260,8 @@ def forgot_password(): do_flash(value[0], 'error') return render_template('security/forgot_password.html', - forgot_password_form=form) + forgot_password_form=form, + **_security._run_ctx_processor('forgot_password')) @anonymous_user_required @@ -297,7 +302,8 @@ def reset_password(token): return render_template('security/reset_password.html', reset_password_form=form, - password_reset_token=token) + reset_password_token=token, + **_security._run_ctx_processor('reset_password')) def create_blueprint(app, name, import_name, **kwargs): From b7d71f8c59064aa6d51ea32a1981e5da8f784ffa Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 17 Aug 2012 12:52:05 -0400 Subject: [PATCH 162/234] Remove old commit param --- flask_security/core.py | 1 - flask_security/datastore.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index b548b293..4af1d60a 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -42,7 +42,6 @@ 'REGISTER_URL': '/register', 'RESET_URL': '/reset', 'CONFIRM_URL': '/confirm', - 'LOGIN_VIEW': '/login', 'POST_LOGIN_VIEW': '/', 'POST_LOGOUT_VIEW': '/', 'POST_FORGOT_VIEW': None, diff --git a/flask_security/datastore.py b/flask_security/datastore.py index d80f1146..1322b83a 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -155,7 +155,7 @@ def add_role_to_user(self, user, role): """ return self._save_model(self._do_add_role(user, role)) - def remove_role_from_user(self, user, role, commit=True): + def remove_role_from_user(self, user, role): """Removes a role from a user if the user has the role. Returns the modified user. @@ -171,7 +171,7 @@ def deactivate_user(self, user): """ return self._save_model(self._do_deactive_user(user)) - def activate_user(self, user, commit=True): + def activate_user(self, user): """Activates a user and returns the modified user. :param user: A User instance or a user identifier From a39f46854e478f06544b0dd32f779d6416e02d7b Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 17 Aug 2012 13:19:40 -0400 Subject: [PATCH 163/234] Significant design change: commit data after some requests to avoid multiple database hits when using SQLALchemy --- example/app.py | 2 ++ flask_security/datastore.py | 7 +++++-- flask_security/views.py | 13 ++++++++++++- setup.py | 2 +- tests/functional_tests.py | 1 + 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/example/app.py b/example/app.py index 1d7b596e..985a3c64 100644 --- a/example/app.py +++ b/example/app.py @@ -24,6 +24,7 @@ def create_roles(): for role in ('admin', 'editor', 'author'): current_app.security.datastore.create_role(name=role) + current_app.security.datastore._commit() def create_users(): @@ -34,6 +35,7 @@ def create_users(): ('tiya@lp.com', 'password', [], False)): current_app.security.datastore.create_user( email=u[0], password=u[1], roles=u[2], active=u[3]) + current_app.security.datastore._commit() def populate_data(): diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 1322b83a..7379eeb7 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -33,6 +33,9 @@ def __init__(self, db, user_model, role_model): self.user_model = user_model self.role_model = role_model + def _commit(self, *args, **kwargs): + pass + def _save_model(self, model, **kwargs): raise NotImplementedError( "User datastore does not implement _save_model method") @@ -218,15 +221,15 @@ class User(db.Model, UserMixin): Security(app, SQLAlchemyUserDatastore(db, User, Role)) """ + def _commit(self, *args, **kwargs): + self.db.session.commit() def _save_model(self, model): self.db.session.add(model) - self.db.session.commit() return model def _delete_model(self, model): self.db.session.delete(model) - self.db.session.commit() def _do_find_user(self, **kwargs): return self.user_model.query.filter_by(**kwargs).first() diff --git a/flask_security/views.py b/flask_security/views.py index 8390dfe1..60baed94 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -10,7 +10,7 @@ """ from flask import current_app as app, redirect, request, \ - render_template, jsonify, Blueprint + render_template, jsonify, after_this_request, Blueprint from werkzeug.datastructures import MultiDict from werkzeug.local import LocalProxy @@ -64,12 +64,19 @@ def _json_auth_error(msg): return resp +def _commit_data(response=None): + _datastore._commit() + return response + + def authenticate(): """View function which handles an authentication request.""" confirm_url = None form_data = MultiDict(request.json) if request.json else request.form form = LoginForm(form_data) + after_this_request(_commit_data) + try: user = _security.auth_provider.authenticate(form) @@ -130,6 +137,9 @@ def register(): # Create user u = _datastore.create_user(**form.to_dict()) + # Save the user so the ID is created + _commit_data() + # Send confirmation instructions if necessary t = reset_confirmation_token(u) if _security.confirmable else None @@ -212,6 +222,7 @@ def send_confirmation(): def confirm_email(token): """View function which handles a email confirmation request.""" + after_this_request(_commit_data) try: user = confirm_by_token(token) diff --git a/setup.py b/setup.py index eb80240c..c24e762c 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ include_package_data=True, platforms='any', install_requires=[ - 'Flask>=0.8', + 'Flask>=0.9', 'Flask-Login>=0.1.3', 'Flask-Principal>=0.3', 'Flask-WTF>=0.5.4', diff --git a/tests/functional_tests.py b/tests/functional_tests.py index f8d3ef3a..6adcc84f 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -182,6 +182,7 @@ def test_user_deleted_during_session_reverts_to_anonymous_user(self): with self.app.test_request_context('/'): user = self.app.security.datastore.find_user(email='matt@lp.com') self.app.security.datastore.delete_user(user) + self.app.security.datastore._commit() r = self._get('/') self.assertNotIn('Hello matt@lp.com', r.data) From 2bd19f999dc7e686fb76d1173d6b973c2766a44b Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 17 Aug 2012 14:46:30 -0400 Subject: [PATCH 164/234] Code cleanup --- flask_security/__init__.py | 3 +- flask_security/confirmable.py | 21 +--- flask_security/core.py | 1 - flask_security/passwordless.py | 22 ++-- flask_security/recoverable.py | 30 ++--- flask_security/signals.py | 4 +- flask_security/utils.py | 6 +- flask_security/views.py | 196 +++++++++++++++------------------ 8 files changed, 116 insertions(+), 167 deletions(-) diff --git a/flask_security/__init__.py b/flask_security/__init__.py index 1f72c84d..e8364fc8 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -20,6 +20,5 @@ from .forms import ForgotPasswordForm, LoginForm, RegisterForm, \ ResetPasswordForm, PasswordlessLoginForm from .signals import confirm_instructions_sent, password_reset, \ - password_reset_requested, reset_instructions_sent, user_confirmed, \ - user_registered + reset_password_instructions_sent, user_confirmed, user_registered from .utils import login_user, logout_user, url_for_security diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 85b2e5d3..cf738f0c 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -26,12 +26,13 @@ _datastore = LocalProxy(lambda: _security.datastore) -def send_confirmation_instructions(user, token): +def send_confirmation_instructions(user): """Sends the confirmation instructions email for the specified user. :param user: The user to send the instructions to :param token: The confirmation token """ + token = generate_confirmation_token(user) url = url_for_security('confirm_email', token=token) confirmation_link = request.url_root[:-1] + url @@ -43,6 +44,8 @@ def send_confirmation_instructions(user, token): confirm_instructions_sent.send(user, app=app._get_current_object()) + return token + def generate_confirmation_token(user): """Generates a unique confirmation token for the specified user. @@ -92,19 +95,3 @@ def confirm_by_token(token): except BadSignature: raise ConfirmationError(get_message('INVALID_CONFIRMATION_TOKEN')[0]) - - -def reset_confirmation_token(user): - """Resets the specified user's confirmation token and sends the user - an email with instructions explaining next steps. - - :param user: The user to work with - """ - token = generate_confirmation_token(user) - - user.confirmed_at = None - _datastore._save_model(user) - - send_confirmation_instructions(user, token) - - return token diff --git a/flask_security/core.py b/flask_security/core.py index 4af1d60a..ec67e626 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -49,7 +49,6 @@ 'POST_REGISTER_VIEW': None, 'POST_CONFIRM_VIEW': None, 'POST_RESET_VIEW': None, - 'RESET_PASSWORD_ERROR_VIEW': None, 'UNAUTHORIZED_VIEW': None, 'DEFAULT_ROLES': [], 'CONFIRMABLE': False, diff --git a/flask_security/passwordless.py b/flask_security/passwordless.py index cc3332c5..49394a4b 100644 --- a/flask_security/passwordless.py +++ b/flask_security/passwordless.py @@ -16,7 +16,7 @@ from .exceptions import PasswordlessLoginError from .signals import login_instructions_sent from .utils import send_mail, md5, get_max_age, login_user, get_message, \ - url_for_security + url_for_security, get_url # Convenient references @@ -47,6 +47,7 @@ def send_login_instructions(user, next): def generate_login_token(user, next): + next = next or get_url(_security.post_login_view) data = [user.id, md5(user.password), next] return _security.login_serializer.dumps(data) @@ -56,20 +57,19 @@ def login_by_token(token): max_age = get_max_age('LOGIN') try: - data = serializer.loads(token, max_age=max_age) - user = _datastore.find_user(id=data[0]) - + user_id, pw, next = serializer.loads(token, max_age=max_age) + user = _datastore.find_user(id=user_id) login_user(user, True) - - return user, data[2] + return user, next except SignatureExpired: sig_okay, data = serializer.loads_unsafe(token) + user_id, pw, next = data user = _datastore.find_user(id=data[0]) - msg = get_message('LOGIN_EXPIRED', - within=_security.login_within, - email=user.email)[0] - raise PasswordlessLoginError(msg, user=user, next=data[2]) + within = _security.login_within + msg = get_message('LOGIN_EXPIRED', within=within, email=user.email) + raise PasswordlessLoginError(msg[0], user=user, next=next) except BadSignature: - raise PasswordlessLoginError(get_message('INVALID_LOGIN_TOKEN')[0]) + msg = get_message('INVALID_LOGIN_TOKEN') + raise PasswordlessLoginError(msg[0]) diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 15e34b6f..d323894e 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -13,9 +13,8 @@ from flask import current_app as app, request from werkzeug.local import LocalProxy -from .exceptions import ResetPasswordError, UserNotFoundError -from .signals import password_reset, password_reset_requested, \ - reset_instructions_sent +from .exceptions import ResetPasswordError +from .signals import password_reset, reset_password_instructions_sent from .utils import send_mail, get_max_age, md5, get_message, encrypt_password, \ url_for_security @@ -26,12 +25,13 @@ _datastore = LocalProxy(lambda: _security.datastore) -def send_reset_password_instructions(user, reset_token): +def send_reset_password_instructions(user): """Sends the reset password instructions email for the specified user. :param user: The user to send the instructions to """ - url = url_for_security('reset_password', token=reset_token) + token = generate_reset_password_token(user) + url = url_for_security('reset_password', token=token) reset_link = request.url_root[:-1] + url @@ -40,8 +40,8 @@ def send_reset_password_instructions(user, reset_token): 'reset_instructions', dict(user=user, reset_link=reset_link)) - reset_instructions_sent.send(dict(user=user, token=reset_token), - app=app._get_current_object()) + reset_password_instructions_sent.send(dict(user=user, token=token), + app=app._get_current_object()) def send_password_reset_notice(user): @@ -99,19 +99,3 @@ def reset_by_token(token, password): except BadSignature: raise ResetPasswordError(get_message('INVALID_RESET_PASSWORD_TOKEN')[0]) - - -def reset_password_reset_token(user): - """Resets the specified user's reset password token and sends the user - an email with instructions explaining next steps. - - :param user: The user to work with - """ - token = generate_reset_password_token(user) - - send_reset_password_instructions(user, token) - - password_reset_requested.send(dict(user=user, token=token), - app=app._get_current_object()) - - return token diff --git a/flask_security/signals.py b/flask_security/signals.py index dcf30916..17aac643 100644 --- a/flask_security/signals.py +++ b/flask_security/signals.py @@ -24,6 +24,4 @@ password_reset = signals.signal("password-reset") -password_reset_requested = signals.signal("password-reset-requested") - -reset_instructions_sent = signals.signal("reset-instructions-sent") +reset_password_instructions_sent = signals.signal("password-reset-instructions-sent") diff --git a/flask_security/utils.py b/flask_security/utils.py index 0ca02453..7041040c 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -24,7 +24,7 @@ from werkzeug.local import LocalProxy from .core import current_user -from .signals import user_registered, password_reset_requested, \ +from .signals import user_registered, reset_password_instructions_sent, \ login_instructions_sent @@ -292,9 +292,9 @@ def capture_reset_password_requests(reset_password_sent_at=None): def _on(request, app): reset_requests.append(request) - password_reset_requested.connect(_on) + reset_password_instructions_sent.connect(_on) try: yield reset_requests finally: - password_reset_requested.disconnect(_on) + reset_password_instructions_sent.disconnect(_on) diff --git a/flask_security/views.py b/flask_security/views.py index 60baed94..4b2a8fd9 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -14,7 +14,8 @@ from werkzeug.datastructures import MultiDict from werkzeug.local import LocalProxy -from flask_security.confirmable import confirm_by_token, reset_confirmation_token +from flask_security.confirmable import confirm_by_token, \ + send_confirmation_instructions from flask_security.decorators import login_required from flask_security.exceptions import ConfirmationError, BadCredentialsError, \ ResetPasswordError, PasswordlessLoginError @@ -22,11 +23,11 @@ ResetPasswordForm, SendConfirmationForm, PasswordlessLoginForm from flask_security.passwordless import send_login_instructions, login_by_token from flask_security.recoverable import reset_by_token, \ - reset_password_reset_token + send_reset_password_instructions from flask_security.signals import user_registered from flask_security.utils import get_url, get_post_login_redirect, do_flash, \ - get_message, config_value, login_user, logout_user, url_for_security, \ - anonymous_user_required + get_message, config_value, login_user, logout_user, \ + anonymous_user_required, url_for_security as url_for # Convenient references @@ -64,36 +65,39 @@ def _json_auth_error(msg): return resp -def _commit_data(response=None): +def _commit(response=None): _datastore._commit() return response +def _ctx(endpoint): + return _security._run_ctx_processor(endpoint) + + def authenticate(): """View function which handles an authentication request.""" - confirm_url = None - form_data = MultiDict(request.json) if request.json else request.form - form = LoginForm(form_data) - after_this_request(_commit_data) + form = LoginForm(request.form) + user, msg, confirm_url = None, None, None + + if request.json: + form = LoginForm(MultiDict(request.json)) try: user = _security.auth_provider.authenticate(form) + except ConfirmationError, e: + msg = str(e) + confirm_url = url_for('send_confirmation', email=e.user.email) + except BadCredentialsError, e: + msg = str(e) + if user: if login_user(user, remember=form.remember.data): + after_this_request(_commit) if request.json: return _json_auth_ok(user) - return redirect(get_post_login_redirect()) - - raise BadCredentialsError(get_message('DISABLED_ACCOUNT')[0]) - - except ConfirmationError, e: - msg = str(e) - confirm_url = url_for_security('send_confirmation', email=e.user.email) - - except BadCredentialsError, e: - msg = str(e) + msg = get_message('DISABLED_ACCOUNT')[0] _logger.debug('Unsuccessful authentication attempt: %s' % msg) @@ -101,18 +105,21 @@ def authenticate(): return _json_auth_error(msg) do_flash(msg, 'error') - - return redirect(confirm_url or url_for_security('login')) + return redirect(confirm_url or url_for('login')) @anonymous_user_required def login(): """View function for login view""" - form = PasswordlessLoginForm() if _security.passwordless else LoginForm() - template = 'send_login' if _security.passwordless else 'login' - return render_template('security/%s.html' % template, login_form=form, - **_security._run_ctx_processor('login')) + tmp, form = '', LoginForm + + if _security.passwordless: + tmp, form = 'send_', PasswordlessLoginForm + + return render_template('security/%slogin.html' % tmp, + login_form=form(), + **_ctx('login')) @login_required @@ -120,11 +127,10 @@ def logout(): """View function which handles a logout request.""" logout_user() - _logger.debug('User logged out') - - return redirect(request.args.get('next', None) or - get_url(_security.post_logout_view)) + next_url = request.args.get('next', None) + post_logout_url = get_url(_security.post_logout_view) + return redirect(next_url or post_logout_url) @anonymous_user_required @@ -133,51 +139,51 @@ def register(): form = RegisterForm(csrf_enabled=not app.testing) - if form.validate_on_submit(): - # Create user - u = _datastore.create_user(**form.to_dict()) - - # Save the user so the ID is created - _commit_data() + if not form.validate_on_submit(): + return render_template('security/register.html', + register_user_form=form, + **_ctx('register')) - # Send confirmation instructions if necessary - t = reset_confirmation_token(u) if _security.confirmable else None + token = None + user = _datastore.create_user(**form.to_dict()) + _commit() - data = dict(user=u, confirm_token=t) - user_registered.send(data, app=app._get_current_object()) + if _security.confirmable: + token = send_confirmation_instructions(user) + do_flash(*get_message('CONFIRM_REGISTRATION', email=user.email)) - _logger.debug('User %s registered' % u) + user_registered.send(dict(user=user, confirm_token=token), + app=app._get_current_object()) - # Login the user if allowed - if not _security.confirmable or _security.login_without_confirmation: - login_user(u) + _logger.debug('User %s registered' % user) - if _security.confirmable: - do_flash(*get_message('CONFIRM_REGISTRATION', email=u.email)) + if not _security.confirmable or _security.login_without_confirmation: + after_this_request(_commit) + login_user(user) - return redirect(get_url(_security.post_register_view) or - get_url(_security.post_login_view)) + post_register_url = get_url(_security.post_register_view) + post_login_url = get_url(_security.post_login_view) - return render_template('security/register.html', - register_user_form=form, - **_security._run_ctx_processor('register')) + return redirect(post_register_url or post_login_url) @anonymous_user_required def send_login(): """View function that sends login instructions for passwordless login""" - form = PasswordlessLoginForm() + form = PasswordlessLoginForm() user = _datastore.find_user(**form.to_dict()) if user.is_active(): send_login_instructions(user, form.next.data) - do_flash(*get_message('LOGIN_EMAIL_SENT', email=user.email)) + msg = get_message('LOGIN_EMAIL_SENT', email=user.email) else: - do_flash(*get_message('DISABLED_ACCOUNT')) + msg = get_message('DISABLED_ACCOUNT') - return render_template('security/send_login.html', login_form=form, - **_security._run_ctx_processor('send_login')) + do_flash(*msg) + return render_template('security/send_login.html', + login_form=form, + **_ctx('send_login')) @anonymous_user_required @@ -186,18 +192,14 @@ def token_login(token): try: user, next = login_by_token(token) - except PasswordlessLoginError, e: if e.user: send_login_instructions(e.user, e.next) - do_flash(str(e), 'error') - - return redirect(request.referrer or url_for_security('login')) + return redirect(request.referrer or url_for('login')) do_flash(*get_message('PASSWORDLESS_LOGIN_SUCCESSFUL')) - - return redirect(next or get_url(_security.post_login_view)) + return redirect(next) @anonymous_user_required @@ -208,45 +210,35 @@ def send_confirmation(): if form.validate_on_submit(): user = _datastore.find_user(**form.to_dict()) - - reset_confirmation_token(user) - + send_confirmation_instructions(user) _logger.debug('%s request confirmation instructions' % user) - do_flash(*get_message('CONFIRMATION_REQUEST', email=user.email)) return render_template('security/send_confirmation.html', reset_confirmation_form=form, - **_security._run_ctx_processor('send_confirmation')) + **_ctx('send_confirmation')) def confirm_email(token): """View function which handles a email confirmation request.""" - after_this_request(_commit_data) + after_this_request(_commit) try: user = confirm_by_token(token) - _logger.debug('%s confirmed their email' % user) - except ConfirmationError, e: - msg = (str(e), 'error') - - _logger.debug('Confirmation error: ' + msg[0]) - + _logger.debug('Confirmation error: %s' % e) if e.user: - reset_confirmation_token(e.user) - - do_flash(*msg) - - return redirect(get_url(_security.confirm_error_view) or - url_for_security('send_confirmation')) + send_confirmation_instructions(e.user) + do_flash(str(e), 'error') + confirm_error_url = get_url(_security.confirm_error_view) + return redirect(confirm_error_url or url_for('send_confirmation')) + _logger.debug('%s confirmed their email' % user) do_flash(*get_message('EMAIL_CONFIRMED')) - login_user(user, True) - - return redirect(get_url(_security.post_confirm_view) or - get_url(_security.post_login_view)) + post_confirm_url = get_url(_security.post_confirm_view) + post_login_url = get_url(_security.post_login_view) + return redirect(post_confirm_url or post_login_url) @anonymous_user_required @@ -257,11 +249,8 @@ def forgot_password(): if form.validate_on_submit(): user = _datastore.find_user(**form.to_dict()) - - reset_password_reset_token(user) - + send_reset_password_instructions(user) _logger.debug('%s requested to reset their password' % user) - do_flash(*get_message('PASSWORD_RESET_REQUEST', email=user.email)) if _security.post_forgot_view: @@ -272,49 +261,42 @@ def forgot_password(): return render_template('security/forgot_password.html', forgot_password_form=form, - **_security._run_ctx_processor('forgot_password')) + **_ctx('forgot_password')) @anonymous_user_required def reset_password(token): """View function that handles a reset password request.""" + next, msg = None, None form = ResetPasswordForm(csrf_enabled=not app.testing) if form.validate_on_submit(): try: user = reset_by_token(token=token, **form.to_dict()) - - _logger.debug('%s reset their password' % user) - - do_flash(*get_message('PASSWORD_RESET')) - - login_user(user) - - return redirect(get_url(_security.post_reset_view) or - get_url(_security.post_login_view)) - + msg = get_message('PASSWORD_RESET') + next = (get_url(_security.post_reset_view) or + get_url(_security.post_login_view)) except ResetPasswordError, e: msg = (str(e), 'error') - - _logger.debug('Password reset error: ' + msg[0]) - if e.user: - reset_password_reset_token(e.user) - + send_reset_password_instructions(e.user) msg = get_message('PASSWORD_RESET_EXPIRED', within=_security.reset_password_within, email=e.user.email) + _logger.debug('Password reset error: ' + msg[0]) - do_flash(*msg) + do_flash(*msg) - if _security.reset_password_error_view: - return redirect(get_url(_security.reset_password_error_view)) + if next: + login_user(user) + _logger.debug('%s reset their password' % user) + return redirect(next) return render_template('security/reset_password.html', reset_password_form=form, reset_password_token=token, - **_security._run_ctx_processor('reset_password')) + **_ctx('reset_password')) def create_blueprint(app, name, import_name, **kwargs): From 9c189f9083356a481bb69b757d82bee4b9f2523b Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 17 Aug 2012 14:50:49 -0400 Subject: [PATCH 165/234] Clean up --- flask_security/utils.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/flask_security/utils.py b/flask_security/utils.py index 7041040c..6e345346 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -66,10 +66,8 @@ def login_user(user, remember=True): user.login_count = user.login_count + 1 if user.login_count else 1 _datastore._save_model(user) - identity_changed.send(current_app._get_current_object(), identity=Identity(user.id)) - _logger.debug('User %s logged in' % user) return True @@ -77,10 +75,8 @@ def login_user(user, remember=True): def logout_user(): for key in ('identity.name', 'identity.auth_type'): session.pop(key, None) - identity_changed.send(current_app._get_current_object(), identity=AnonymousIdentity()) - _logout_user() @@ -157,10 +153,9 @@ def find_redirect(key): :param key: The session or application configuration key to search for """ - result = (get_url(session.pop(key.lower(), None)) or - get_url(current_app.config[key.upper()] or None) or '/') - - return result + rv = (get_url(session.pop(key.lower(), None)) or + get_url(current_app.config[key.upper()] or None) or '/') + return rv def get_config(app): @@ -238,10 +233,9 @@ def send_mail(subject, recipient, template, context=None): sender=_security.email_sender, recipients=[recipient]) - base = 'security/email' - msg.body = render_template('%s/%s.txt' % (base, template), **context) - msg.html = render_template('%s/%s.html' % (base, template), **context) - + ctx = ('security/email', template) + msg.body = render_template('%s/%s.txt' % ctx, **context) + msg.html = render_template('%s/%s.html' % ctx, **context) mail.send(msg) From c36fee7fda9f1cc31efa507327bede734689eb06 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 17 Aug 2012 15:05:22 -0400 Subject: [PATCH 166/234] Clean up --- flask_security/confirmable.py | 7 +----- flask_security/core.py | 11 +++------ flask_security/datastore.py | 2 -- flask_security/decorators.py | 46 +++++++++-------------------------- flask_security/recoverable.py | 4 --- 5 files changed, 15 insertions(+), 55 deletions(-) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index cf738f0c..e8e9bec6 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -12,7 +12,7 @@ from datetime import datetime from itsdangerous import BadSignature, SignatureExpired -from flask import current_app as app, request, url_for +from flask import current_app as app, request from werkzeug.local import LocalProxy from .exceptions import ConfirmationError @@ -34,16 +34,13 @@ def send_confirmation_instructions(user): """ token = generate_confirmation_token(user) url = url_for_security('confirm_email', token=token) - confirmation_link = request.url_root[:-1] + url - ctx = dict(user=user, confirmation_link=confirmation_link) send_mail('Please confirm your email', user.email, 'confirmation_instructions', ctx) confirm_instructions_sent.send(user, app=app._get_current_object()) - return token @@ -80,9 +77,7 @@ def confirm_by_token(token): user.confirmed_at = datetime.utcnow() _datastore._save_model(user) - user_confirmed.send(user, app=app._get_current_object()) - return user except SignatureExpired: diff --git a/flask_security/core.py b/flask_security/core.py index ec67e626..2927d8a8 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -227,8 +227,7 @@ def _add_ctx_processor(self, endpoint, fn): c[endpoint].append(fn) def _run_ctx_processor(self, endpoint): - fns = [] - rv = {} + rv, fns = {}, [] for g in ['all', endpoint]: if g in self.context_processors: @@ -324,20 +323,16 @@ def _get_state(self, app, datastore): ('principal', _get_principal(app)), ('pwd_context', _get_pwd_context(app)), ('remember_token_serializer', _get_remember_token_serializer(app)), - ('token_auth_serializer', _get_token_auth_serializer(app))]: + ('token_auth_serializer', _get_token_auth_serializer(app)), + ('context_processors', {})]: kwargs[key] = value - kwargs['context_processors'] = {} - kwargs['login_serializer'] = ( _get_login_serializer(app) if kwargs['passwordless'] else None) - kwargs['reset_serializer'] = ( _get_reset_serializer(app) if kwargs['recoverable'] else None) - kwargs['confirm_serializer'] = ( _get_confirm_serializer(app) if kwargs['confirmable'] else None) - return _SecurityState(**kwargs) def __getattr__(self, name): diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 7379eeb7..0ba376b6 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -83,7 +83,6 @@ def _prepare_role_modify_args(self, user, role): def _prepare_create_user_args(self, **kwargs): kwargs.setdefault('active', True) kwargs.setdefault('roles', _security.default_roles) - roles = kwargs.get('roles', []) for i, role in enumerate(roles): @@ -92,7 +91,6 @@ def _prepare_create_user_args(self, **kwargs): roles[i] = self.find_role(rn) kwargs['roles'] = roles - pwd_context = _security.pwd_context pw = kwargs['password'] diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 9b3d7e3c..f6aedda2 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -12,9 +12,8 @@ from functools import wraps from flask import current_app, Response, request, redirect -from flask.ext.login import login_required, login_url, current_user +from flask.ext.login import current_user from flask.ext.principal import RoleNeed, Permission, Identity, identity_changed -from itsdangerous import BadSignature from werkzeug.local import LocalProxy from . import utils @@ -50,19 +49,19 @@ def _get_unauthorized_view(): def _check_token(): header_key = _security.token_authentication_header args_key = _security.token_authentication_key - header_token = request.headers.get(header_key, None) token = request.args.get(args_key, header_token) - serializer = _security.remember_token_serializer + rv = False try: data = serializer.loads(token) user = _security.datastore.find_user(id=data[0]) + rv = utils.md5(user.password) == data[1] except: - return False + pass - return True if utils.md5(user.password) == data[1] else False + return rv def _check_http_auth(): @@ -70,19 +69,15 @@ def _check_http_auth(): try: user = _security.datastore.find_user(email=auth.username) + if utils.verify_password(auth.password, user.password, + salt=_security.password_salt, + use_hmac=_security.password_hmac): + identity_changed.send(current_app._get_current_object(), + identity=Identity(user.id)) + return True except UserNotFoundError: return False - rv = utils.verify_password(auth.password, user.password, - salt=_security.password_salt, - use_hmac=_security.password_hmac) - - if rv: - identity_changed.send(current_app._get_current_object(), - identity=Identity(user.id)) - - return rv - def http_auth_required(realm): """Decorator that protects endpoints using Basic HTTP authentication. @@ -95,17 +90,13 @@ def decorator(fn): def wrapper(*args, **kwargs): if _check_http_auth(): return fn(*args, **kwargs) - r = _security.default_http_auth_realm if callable(realm) else realm h = {'WWW-Authenticate': 'Basic realm="%s"' % r} - return _get_unauthorized_response(headers=h) - return wrapper if callable(realm): return decorator(realm) - return decorator @@ -121,9 +112,7 @@ def auth_token_required(fn): def decorated(*args, **kwargs): if _check_token(): return fn(*args, **kwargs) - return _get_unauthorized_response() - return decorated @@ -142,22 +131,16 @@ def dashboard(): :param args: The required roles. """ def wrapper(fn): - @wraps(fn) def decorated_view(*args, **kwargs): perms = [Permission(RoleNeed(role)) for role in roles] - for perm in perms: if not perm.can(): _logger.debug('Identity does not provide the ' 'roles: %s' % [r for r in roles]) - return _get_unauthorized_view() - return fn(*args, **kwargs) - return decorated_view - return wrapper @@ -176,22 +159,15 @@ def create_post(): :param args: The possible roles. """ def wrapper(fn): - @wraps(fn) def decorated_view(*args, **kwargs): perm = Permission(*[RoleNeed(role) for role in roles]) - if perm.can(): return fn(*args, **kwargs) - r1 = [r for r in roles] r2 = [r.name for r in current_user.roles] - _logger.debug('Current user does not provide a required role. ' 'Accepted: %s Provided: %s' % (r1, r2)) - return _get_unauthorized_view() - return decorated_view - return wrapper diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index d323894e..d472fc22 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -32,7 +32,6 @@ def send_reset_password_instructions(user): """ token = generate_reset_password_token(user) url = url_for_security('reset_password', token=token) - reset_link = request.url_root[:-1] + url send_mail('Password reset instructions', @@ -85,11 +84,8 @@ def reset_by_token(token, password): use_hmac=_security.password_hmac) _datastore._save_model(user) - send_password_reset_notice(user) - password_reset.send(user, app=app._get_current_object()) - return user except SignatureExpired: From a4356d786ef26ef22d6e8019e50dd68852822d68 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 17 Aug 2012 15:06:54 -0400 Subject: [PATCH 167/234] More clean up --- 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 f6aedda2..7a163f22 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -12,7 +12,7 @@ from functools import wraps from flask import current_app, Response, request, redirect -from flask.ext.login import current_user +from flask.ext.login import current_user, login_required from flask.ext.principal import RoleNeed, Permission, Identity, identity_changed from werkzeug.local import LocalProxy From 433b88e5796d0dbcf0f1de56577579eeb62f56ec Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 17 Aug 2012 15:18:05 -0400 Subject: [PATCH 168/234] Update templates --- flask_security/templates/security/_help.html | 14 ++++++++++++++ .../templates/security/forgot_password.html | 3 ++- flask_security/templates/security/login.html | 10 +--------- flask_security/templates/security/register.html | 3 ++- .../templates/security/reset_password.html | 3 ++- .../templates/security/send_confirmation.html | 3 ++- flask_security/templates/security/send_login.html | 3 ++- 7 files changed, 25 insertions(+), 14 deletions(-) create mode 100644 flask_security/templates/security/_help.html diff --git a/flask_security/templates/security/_help.html b/flask_security/templates/security/_help.html new file mode 100644 index 00000000..f478be19 --- /dev/null +++ b/flask_security/templates/security/_help.html @@ -0,0 +1,14 @@ +{% if security.registerable or security.recoverable or security.confirmabled %} +

        Help

        + +{% endif %} \ No newline at end of file diff --git a/flask_security/templates/security/forgot_password.html b/flask_security/templates/security/forgot_password.html index be818dc1..d1c3158c 100644 --- a/flask_security/templates/security/forgot_password.html +++ b/flask_security/templates/security/forgot_password.html @@ -5,4 +5,5 @@

        Send password reset instructions

        {{ forgot_password_form.hidden_tag() }} {{ render_field_with_errors(forgot_password_form.email) }} {{ render_field(forgot_password_form.submit) }} - \ No newline at end of file + +{% include "security/_help.html" %} \ No newline at end of file diff --git a/flask_security/templates/security/login.html b/flask_security/templates/security/login.html index 9f2b39a7..59f1700d 100644 --- a/flask_security/templates/security/login.html +++ b/flask_security/templates/security/login.html @@ -9,12 +9,4 @@

        Login

        {{ render_field(login_form.next) }} {{ render_field(login_form.submit) }} -

        Help

        -

        - {% if security.recoverable %} - Forgot password
        - {% endif %} - {% if security.confirmable %} - Confirm account - {% endif %} -

        \ No newline at end of file +{% include "security/_help.html" %} \ No newline at end of file diff --git a/flask_security/templates/security/register.html b/flask_security/templates/security/register.html index 3219472f..ab7cce1f 100644 --- a/flask_security/templates/security/register.html +++ b/flask_security/templates/security/register.html @@ -7,4 +7,5 @@

        Register

        {{ render_field_with_errors(register_user_form.password) }} {{ render_field_with_errors(register_user_form.password_confirm) }} {{ render_field(register_user_form.submit) }} - \ No newline at end of file + +{% include "security/_help.html" %} \ No newline at end of file diff --git a/flask_security/templates/security/reset_password.html b/flask_security/templates/security/reset_password.html index 0adcad84..ecf6d9a8 100644 --- a/flask_security/templates/security/reset_password.html +++ b/flask_security/templates/security/reset_password.html @@ -6,4 +6,5 @@

        Reset password

        {{ render_field_with_errors(reset_password_form.password) }} {{ render_field_with_errors(reset_password_form.password_confirm) }} {{ render_field(reset_password_form.submit) }} - \ No newline at end of file + +{% include "security/_help.html" %} \ No newline at end of file diff --git a/flask_security/templates/security/send_confirmation.html b/flask_security/templates/security/send_confirmation.html index 3c905238..f4e1a25b 100644 --- a/flask_security/templates/security/send_confirmation.html +++ b/flask_security/templates/security/send_confirmation.html @@ -5,4 +5,5 @@

        Resend confirmation instructions

        {{ reset_confirmation_form.hidden_tag() }} {{ render_field_with_errors(reset_confirmation_form.email) }} {{ render_field(reset_confirmation_form.submit) }} - \ No newline at end of file + +{% include "security/_help.html" %} \ No newline at end of file diff --git a/flask_security/templates/security/send_login.html b/flask_security/templates/security/send_login.html index 7a3ea316..f3115290 100644 --- a/flask_security/templates/security/send_login.html +++ b/flask_security/templates/security/send_login.html @@ -6,4 +6,5 @@

        Login

        {{ render_field_with_errors(login_form.email) }} {{ render_field(login_form.next) }} {{ render_field(login_form.submit) }} - \ No newline at end of file + +{% include "security/_help.html" %} \ No newline at end of file From f339018a5e69c489214fc7e8c41635f199935e7f Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 17 Aug 2012 15:30:24 -0400 Subject: [PATCH 169/234] Update template --- flask_security/templates/security/_help.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flask_security/templates/security/_help.html b/flask_security/templates/security/_help.html index f478be19..d50b5a40 100644 --- a/flask_security/templates/security/_help.html +++ b/flask_security/templates/security/_help.html @@ -1,6 +1,7 @@ {% if security.registerable or security.recoverable or security.confirmabled %} -

        Help

        +

        Menu

          +
        • Login
        • {% if security.registerable %}
        • Register
        • {% endif %} From 8919129c95bb1e27e30a925240811cf63e13ece9 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 17 Aug 2012 15:31:37 -0400 Subject: [PATCH 170/234] Fiddle with templates again --- flask_security/templates/security/{_help.html => _menu.html} | 0 flask_security/templates/security/forgot_password.html | 2 +- flask_security/templates/security/login.html | 2 +- flask_security/templates/security/register.html | 2 +- flask_security/templates/security/reset_password.html | 2 +- flask_security/templates/security/send_confirmation.html | 2 +- flask_security/templates/security/send_login.html | 2 +- 7 files changed, 6 insertions(+), 6 deletions(-) rename flask_security/templates/security/{_help.html => _menu.html} (100%) diff --git a/flask_security/templates/security/_help.html b/flask_security/templates/security/_menu.html similarity index 100% rename from flask_security/templates/security/_help.html rename to flask_security/templates/security/_menu.html diff --git a/flask_security/templates/security/forgot_password.html b/flask_security/templates/security/forgot_password.html index d1c3158c..90fcaf66 100644 --- a/flask_security/templates/security/forgot_password.html +++ b/flask_security/templates/security/forgot_password.html @@ -6,4 +6,4 @@

          Send password reset instructions

          {{ render_field_with_errors(forgot_password_form.email) }} {{ render_field(forgot_password_form.submit) }} -{% include "security/_help.html" %} \ No newline at end of file +{% include "security/_menu.html" %} \ No newline at end of file diff --git a/flask_security/templates/security/login.html b/flask_security/templates/security/login.html index 59f1700d..35951173 100644 --- a/flask_security/templates/security/login.html +++ b/flask_security/templates/security/login.html @@ -9,4 +9,4 @@

          Login

          {{ render_field(login_form.next) }} {{ render_field(login_form.submit) }} -{% include "security/_help.html" %} \ No newline at end of file +{% include "security/_menu.html" %} \ No newline at end of file diff --git a/flask_security/templates/security/register.html b/flask_security/templates/security/register.html index ab7cce1f..5d477ab7 100644 --- a/flask_security/templates/security/register.html +++ b/flask_security/templates/security/register.html @@ -8,4 +8,4 @@

          Register

          {{ render_field_with_errors(register_user_form.password_confirm) }} {{ render_field(register_user_form.submit) }} -{% include "security/_help.html" %} \ No newline at end of file +{% include "security/_menu.html" %} \ No newline at end of file diff --git a/flask_security/templates/security/reset_password.html b/flask_security/templates/security/reset_password.html index ecf6d9a8..e6fc3f58 100644 --- a/flask_security/templates/security/reset_password.html +++ b/flask_security/templates/security/reset_password.html @@ -7,4 +7,4 @@

          Reset password

          {{ render_field_with_errors(reset_password_form.password_confirm) }} {{ render_field(reset_password_form.submit) }} -{% include "security/_help.html" %} \ No newline at end of file +{% include "security/_menu.html" %} \ No newline at end of file diff --git a/flask_security/templates/security/send_confirmation.html b/flask_security/templates/security/send_confirmation.html index f4e1a25b..d4dc2ed3 100644 --- a/flask_security/templates/security/send_confirmation.html +++ b/flask_security/templates/security/send_confirmation.html @@ -6,4 +6,4 @@

          Resend confirmation instructions

          {{ render_field_with_errors(reset_confirmation_form.email) }} {{ render_field(reset_confirmation_form.submit) }} -{% include "security/_help.html" %} \ No newline at end of file +{% include "security/_menu.html" %} \ No newline at end of file diff --git a/flask_security/templates/security/send_login.html b/flask_security/templates/security/send_login.html index f3115290..640f7444 100644 --- a/flask_security/templates/security/send_login.html +++ b/flask_security/templates/security/send_login.html @@ -7,4 +7,4 @@

          Login

          {{ render_field(login_form.next) }} {{ render_field(login_form.submit) }} -{% include "security/_help.html" %} \ No newline at end of file +{% include "security/_menu.html" %} \ No newline at end of file From a3f350f905199f7d0d5c076c27add16b4345c240 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 20 Aug 2012 17:07:51 -0400 Subject: [PATCH 171/234] Start work on documentation --- README.rst | 3 +- docs/api.rst | 78 +++++++++ docs/conf.py | 6 +- docs/configuration.rst | 63 +++++++ docs/contents.rst.inc | 13 ++ docs/features.rst | 130 +++++++++++++++ docs/index.rst | 370 +---------------------------------------- docs/models.rst | 51 ++++++ docs/overview.rst | 33 ++++ docs/quickstart.rst | 85 ++++++++++ 10 files changed, 462 insertions(+), 370 deletions(-) create mode 100644 docs/api.rst create mode 100644 docs/configuration.rst create mode 100644 docs/contents.rst.inc create mode 100644 docs/features.rst create mode 100644 docs/models.rst create mode 100644 docs/overview.rst create mode 100644 docs/quickstart.rst diff --git a/README.rst b/README.rst index caf3b42d..d6a46a1f 100644 --- a/README.rst +++ b/README.rst @@ -3,8 +3,7 @@ Flask-Security .. image:: https://secure.travis-ci.org/mattupstate/flask-security.png?branch=develop -Flask-Security is a Flask extension that aims to add quick and simple security -to your Flask applications. +Flask-Security quickly adds security features to your Flask application. Resources --------- diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 00000000..ce4d0d8e --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,78 @@ +API +=== + +Core +---- +.. autoclass:: flask_security.core.Security + :members: + +.. data:: flask_security.core.current_user + + A proxy for the current user. + + +Protecting Views +---------------- +.. autofunction:: flask_security.decorators.login_required + +.. autofunction:: flask_security.decorators.roles_required + +.. autofunction:: flask_security.decorators.roles_accepted + +.. autofunction:: flask_security.decorators.http_auth_required + +.. autofunction:: flask_security.decorators.auth_token_required + + +User Object Helpers +------------------- +.. autoclass:: flask_security.core.UserMixin + :members: + +.. autoclass:: flask_security.core.RoleMixin + :members: + +.. autoclass:: flask_security.core.AnonymousUser + :members: + + +Datastores +---------- +.. autoclass:: flask_security.datastore.UserDatastore + :members: + +.. autoclass:: flask_security.datastore.SQLAlchemyUserDatastore + :members: + :inherited-members: + +.. autoclass:: flask_security.datastore.MongoEngineUserDatastore + :members: + :inherited-members: + + +Exceptions +---------- +.. autoexception:: flask_security.exceptions.BadCredentialsError + +.. autoexception:: flask_security.exceptions.AuthenticationError + +.. autoexception:: flask_security.exceptions.UserNotFoundError + +.. autoexception:: flask_security.exceptions.RoleNotFoundError + +.. autoexception:: flask_security.exceptions.UserDatastoreError + +.. autoexception:: flask_security.exceptions.UserCreationError + +.. autoexception:: flask_security.exceptions.RoleCreationError + +.. autoexception:: flask_security.exceptions.ConfirmationError + +.. autoexception:: flask_security.exceptions.ResetPasswordError + + +Signals +------- +See the documentation for the signals provided by the Flask-Login and +Flask-Principal extensions. Flask-Security does not provide any additional +signals. \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 6bc77760..64f49a5f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -92,14 +92,14 @@ # The theme to use for HTML and HTML Help pages. Major themes that come with # Sphinx are currently 'default' and 'sphinxdoc'. -html_theme = 'flask_small' +html_theme = 'flask' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. html_theme_options = { - 'github_fork': 'mattupstate/flask-security', - 'index_logo': False + #'github_fork': 'mattupstate/flask-security', + #'index_logo': False } # Add any paths that contain custom themes here, relative to this directory. diff --git a/docs/configuration.rst b/docs/configuration.rst new file mode 100644 index 00000000..24f4e17f --- /dev/null +++ b/docs/configuration.rst @@ -0,0 +1,63 @@ +Configuration +============= + +Flask-Security configuration options. + +* :attr:`SECURITY_URL_PREFIX`: Specifies the URL prefix for the Security + blueprint. +* :attr:`SECURITY_FLASH_MESSAGES`: Specifies wether or not to flash messages + during security mechanisms. +* :attr:`SECURITY_PASSWORD_HASH`: Specifies the encryption method to use. e.g.: + plaintext, bcrypt, etc. +* :attr:`SECURITY_AUTH_URL`: Specifies the URL to to handle authentication. +* :attr:`SECURITY_LOGOUT_URL`: Specifies the URL to process a logout request. +* :attr:`SECURITY_REGISTER_URL`: Specifies the URL for user registrations. +* :attr:`SECURITY_RESET_URL`: Specifies the URL for password resets. +* :attr:`SECURITY_CONFIRM_URL`: Specifies the URL for account confirmations. +* :attr:`SECURITY_LOGIN_VIEW`: Specifies the URL to redirect to when + authentication is required. +* :attr:`SECURITY_CONFIRM_ERROR_VIEW`: Specifies the URL to redirect to when + an confirmation error occurs. +* :attr:`SECURITY_POST_LOGIN_VIEW`: Specifies the URL to redirect to after a + user logins in. +* :attr:`SECURITY_POST_LOGOUT_VIEW`: Specifies the URL to redirect to after a + user logs out. +* :attr:`SECURITY_POST_FORGOT_VIEW`: Specifies the URL to redirect to after a + user requests password reset instructions. +* :attr:`SECURITY_RESET_PASSWORD_ERROR_VIEW`: Specifies the URL to redirect to + after an error occurs during the password reset process. +* :attr:`SECURITY_POST_REGISTER_VIEW`: Specifies the URL to redirect to after a + user successfully registers. +* :attr:`SECURITY_POST_CONFIRM_VIEW`: Specifies the URL to redirect to after a + user successfully confirms their account. +* :attr:`SECURITY_UNAUTHORIZED_VIEW`: Specifies the URL to redirect to when a + user attempts to access a view they don't have permission to view. +* :attr:`SECURITY_DEFAULT_ROLES`: The default roles any new users should have. +* :attr:`SECURITY_CONFIRMABLE`: Enables confirmation features. Defaults to + `False`. +* :attr:`SECURITY_REGISTERABLE`: Enables user registration features. Defaults to + `False`. +* :attr:`SECURITY_RECOVERABLE`: Enables password reset/recovery features. + Defaults to `False`. +* :attr:`SECURITY_TRACKABLE`: Enables login tracking features. Defaults to + `False`. +* :attr:`SECURITY_CONFIRM_EMAIL_WITHIN`: Specifies the amount of time a user + has to confirm their account/email. Default is `5 days`. +* :attr:`SECURITY_RESET_PASSWORD_WITHIN`: Specifies the amount of time a user + has to reset their password. Default is `5 days`. +* :attr:`SECURITY_LOGIN_WITHOUT_CONFIRMATION`: Specifies if users can login + without first confirming their accounts. Defaults to `False` +* :attr:`SECURITY_EMAIL_SENDER`: Specifies the email address to send emails on + behalf of. Defaults to `no-reply@localhost`. +* :attr:`SECURITY_TOKEN_AUTHENTICATION_KEY`: Specifies the query string argument + to use during token authentication. Defaults to `auth_token`. +* :attr:`SECURITY_TOKEN_AUTHENTICATION_HEADER`: Specifies the header name to use + during token authentication. Defaults to `X-Auth-Token`. +* :attr:`SECURITY_CONFIRM_SALT`: Specifies the salt value to use for account + confirmation tokens. Defaults to `confirm-salt`. +* :attr:`SECURITY_RESET_SALT`: Specifies the salt value to use for password + reset tokens. Defaults to `reset-salt`. +* :attr:`SECURITY_AUTH_SALT`: Specifies the salt value to use for token based + authentication tokens. Defaults to `auth-salt`. +* :attr:`SECURITY_DEFAULT_HTTP_AUTH_REALM`: Specifies the default basic HTTP + authentication realm. Defaults to `Login Required`. \ No newline at end of file diff --git a/docs/contents.rst.inc b/docs/contents.rst.inc new file mode 100644 index 00000000..f0431f1b --- /dev/null +++ b/docs/contents.rst.inc @@ -0,0 +1,13 @@ +Documentation +------------- + +.. toctree:: + :maxdepth: 1 + + overview + features + configuration + quickstart + models + api + changelog \ No newline at end of file diff --git a/docs/features.rst b/docs/features.rst new file mode 100644 index 00000000..aa3b64ed --- /dev/null +++ b/docs/features.rst @@ -0,0 +1,130 @@ +Features +======== + +Flask-Security allows you to quickly add common security mechanisms to your +Flask application. They include: + + +.. session-based-auth: + +Session Based Authentication +---------------------------- + +Session based authentication is fulfilled entirely by the `Flask-Login`_ +extension. Flask-Security handles the configuration of Flask-Login automatically +based on a few of its own configuration values and uses Flask-Login's +`alternative token`_ feature for remembering users when their session has +expired. + + +.. role-management: + +Role/Identity Based Access +-------------------------- + +Flask-Security implements very basic role management out of the box. This means +that you can associate a high level role or multiple roles to any user. For +instance, you may assign roles such as `Admin`, `Editor`, `SuperUser`, or a +combination of said roles to a user. Access control is based on the role name +and all roles should be uniquely named. This feature is implemented using the +`Flask-Principal`_ extension. If you'd like to implement more granular access +control you can refer to the Flask-Princpal `documentation on this topic`_. + + +.. password-encryption: + +Password Encryption +------------------- + +Password encryption is enabled with `passlib`_. Passwords are stored in plain +text by default but you can easily configure the encryption algorithm and salt +value in your application configuration. You should **always use an encryption +algorithm** in your production environment. Bcrypt is a popular algorithm as +of writing this documentation. Bear in mind passlib does not assume which +algorithm you will choose and may require additional libraries to be installed. + + +.. basic-http-auth: + +Basic HTTP Authentication +------------------------- + +Basic HTTP authentication is achievable using a simple view method decorator. +This feature expects the incoming authentication information to identify a user +in the system. This means that the username must be equal to their email address. + + +.. token-authentication: + +Token Authentication +-------------------- + +Token based authentication is enabled by retrieving the user auth token by +performing an HTTP POST with the authentication details as JSON data against the +authentication endpoint. A successful call to this endpoint will return the +user's ID and their authentication token. This token can be used in subsequent +requests to protected resources. The auth token is supplied in the request +through an HTTP header or query string parameter. By default the HTTP header +name is `X-Auth-Token` and the default query string parameter name is +`auth_token`. Authentication tokens are generated using the user's password. +Thus if the user changes his or her password their existing authentication token +will become invalid. A new token will need to be retrieved using the user's new +password. + + +.. email-confirmation: + +Email Confirmation +------------------ + +If desired you can require that new users confirm their email address. +Flask-Security will send an email message to any new users with an confirmation +link. Upon navigating to the confirmation link, the user will be automatically +logged in. There is also view for resending a confirmation link to a given email +if the user happens to try to use an expired token or has lost the previous +email. Confirmation links can be configured to expire after a specified amount +of time. + +.. password-recovery: + +Password Reset/Recovery +----------------------- + +Password reset and recovery is available for when a user forgets his or her +password. Flask-Security sends an email to the user with a link to a view which +they can reset their password. Once the password is reset they are automatically +logged in and can use the new password from then on. Password reset links can +be configured to expire after a specified amount of time. + + +.. user-registration: + +User Registration +----------------- + +Flask-Security comes packaged with a basic user registration view. This view is +very simple and new users need only supply an email address and their password. +This view can be overrided if your registration process requires more fields. + + +.. login-tracking: + +Login Tracking +-------------- + +Flask-Security can, if configured, keep track of basic login events and +statistics. They include: + +* Last login date +* Current login date +* Last login IP address +* Current login IP address +* Total login count + + + +.. _Flask-Login: http://packages.python.org/Flask-Login/ +.. _alternative token: http://packages.python.org/Flask-Login/#alternative-tokens +.. _Flask-Principal: http://packages.python.org/Flask-Principal/ +.. _documentation on this topic: http://packages.python.org/Flask-Principal/#granular-resource-protection +.. _passlib: http://packages.python.org/passlib/ diff --git a/docs/index.rst b/docs/index.rst index 6ef0253c..92747986 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,369 +1,9 @@ -.. include:: ../README.rst +Flask-Security +============== +.. image:: https://secure.travis-ci.org/mattupstate/flask-security.png?branch=develop -Contents -========= -* :ref:`overview` -* :ref:`installation` -* :ref:`quick-start` -* :ref:`models` -* :ref:`flask-script-commands` -* :ref:`api` -* :doc:`Changelog ` +Flask-Security quickly adds security features to your Flask application. -.. _overview: - -Overview -======== - -Flask-Security allows you to quickly add common user and security mechanisms to -your Flask application. They include: - -1. Session based authentication -2. Role management -3. Password encryption -4. Basic HTTP authentication -5. Token based authentication -6. Token based account activation (optional) -7. Token based password recovery/resetting (optional) -8. User registration (optional) -9. Login tracking (optional) -10. Basic user management commands - -Many of these features are made possible by integrating various Flask extensions -and libraries. They include: - -1. `Flask-Login `_ -2. `Flask-Mail `_ -3. `Flask-Principal `_ -4. `Flask-Script `_ -5. `Flask-WTF `_ -6. `itsdangerous `_ -7. `passlib `_ - -Additionally, it assumes you'll be using a common library for your database -connections and model definitions. Flask-Security thus supports SQLAlchemy and -MongoEngine out of the box and additional libraries can easily be supported. - - -.. _installation: - -Installation -============ - -First, install Flask-Security:: - - $ mkvirtualenv app-name - $ pip install Flask-Security - -Then install your datastore requirement. - -**SQLAlchemy**:: - - $ pip install flask-sqlalchemy - -**MongoEngine**:: - - $ pip install flask-mongoengine - -And lastly install any password encryption library that you may need. For -example:: - - $ pip install py-bcrypt - - -.. _quick-start: - -Quick Start Example -=================== - -The following code sample illustrates how to get started as quickly as possible -using SQLAlchemy.:: - - from flask import Flask, render_template, url_for - from flask.ext.sqlalchemy import SQLAlchemy - from flask.ext.security import Security, UserMixin, RoleMixin, \ - login_required - from flask.ext.security.datastore import SQLAlchemyUserDatastore - from flask.ext.security.forms import LoginForm - - app = Flask(__name__) - app.debug = True - app.config['SECRET_KEY'] = 'secret' - app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' - app.config['SECURITY_POST_LOGIN_VIEW'] = '/protected' - - db = SQLAlchemy(app) - - roles_users = db.Table('roles_users', - db.Column('user_id', db.Integer(), db.ForeignKey('user.id')), - db.Column('role_id', db.Integer(), db.ForeignKey('role.id'))) - - class Role(db.Model, RoleMixin): - id = db.Column(db.Integer(), primary_key=True) - name = db.Column(db.String(80), unique=True) - description = db.Column(db.String(255)) - - class User(db.Model, UserMixin): - id = db.Column(db.Integer, primary_key=True) - email = db.Column(db.String(255), unique=True) - password = db.Column(db.String(255)) - active = db.Column(db.Boolean()) - roles = db.relationship('Role', secondary=roles_users, - backref=db.backref('users', lazy='dynamic')) - - datastore = SQLAlchemyUserDatastore(db, User, Role) - - Security(app, datastore) - - @app.before_first_request - def add_user(): - db.create_all() - datastore.create_user(email='matt@matt.com', - password='password') - - @app.route('/') - @app.route('/login') - def login(): - return render_template('security/logins/new.html', - login_form=LoginForm()) - - @app.route('/protected') - @login_required - def protected(): - return """

          You are logged in

          -

          Log out""" % ( - url_for('flask_security.logout')) - - if __name__ == '__main__': - app.run() - - -.. _models: - -Models -====== - -Flask-Security assumes you'll be using libraries such as SQLAlchemy or -MongoEngine to define a data model that includes a `User` and `Role` model. The -fields on your models must follow a particular convention depending on the -functionality your app requires. Aside from this, you're free to add any -additional fields to your model(s) if you want. At the bear minimum your `User` -and `Role` model should include the following fields: - -**User** - -* id -* email -* password -* active - -**Role** - -* id -* name -* description - - -Additional Functionality ------------------------- - -Depending on the application's configuration, additional fields may need to be -added to your `User` model. - -Confirmable -^^^^^^^^^^^ - -If you enable account confirmation by setting your application's -`SECURITY_CONFIRMABLE` configuration value to `True` your `User` model will -require the following additional field: - -* confirmed_at - -Trackable -^^^^^^^^^ - -If you enable user tracking by setting your application's `SECURITY_TRACKABLE` -configuration value to `True` your `User` model will require the following -additional fields: - -* last_login_at -* current_login_at -* last_login_ip -* current_login_ip -* login_count - - -.. _flask-script-commands: - -Flask-Script Commands ---------------------- -Flask-Security comes packed with a few Flask-Script commands. They are: - -* :class:`flask_security.script.CreateUserCommand` -* :class:`flask_security.script.CreateRoleCommand` -* :class:`flask_security.script.AddRoleCommand` -* :class:`flask_security.script.RemoveRoleCommand` -* :class:`flask_security.script.DeactivateUserCommand` -* :class:`flask_security.script.ActivateUserCommand` -* :class:`flask_security.script.ActivateUserCommand` -* :class:`flask_security.script.GenerateBlueprintCommand` - -Register these on your script manager for pure convenience. - - -.. _configuration: - -Configuration Values -==================== - -* :attr:`SECURITY_URL_PREFIX`: Specifies the URL prefix for the Security - blueprint. -* :attr:`SECURITY_FLASH_MESSAGES`: Specifies wether or not to flash messages - during security mechanisms. -* :attr:`SECURITY_PASSWORD_HASH`: Specifies the encryption method to use. e.g.: - plaintext, bcrypt, etc. -* :attr:`SECURITY_AUTH_URL`: Specifies the URL to to handle authentication. -* :attr:`SECURITY_LOGOUT_URL`: Specifies the URL to process a logout request. -* :attr:`SECURITY_REGISTER_URL`: Specifies the URL for user registrations. -* :attr:`SECURITY_RESET_URL`: Specifies the URL for password resets. -* :attr:`SECURITY_CONFIRM_URL`: Specifies the URL for account confirmations. -* :attr:`SECURITY_LOGIN_VIEW`: Specifies the URL to redirect to when - authentication is required. -* :attr:`SECURITY_CONFIRM_ERROR_VIEW`: Specifies the URL to redirect to when - an confirmation error occurs. -* :attr:`SECURITY_POST_LOGIN_VIEW`: Specifies the URL to redirect to after a - user logins in. -* :attr:`SECURITY_POST_LOGOUT_VIEW`: Specifies the URL to redirect to after a - user logs out. -* :attr:`SECURITY_POST_FORGOT_VIEW`: Specifies the URL to redirect to after a - user requests password reset instructions. -* :attr:`SECURITY_RESET_PASSWORD_ERROR_VIEW`: Specifies the URL to redirect to - after an error occurs during the password reset process. -* :attr:`SECURITY_POST_REGISTER_VIEW`: Specifies the URL to redirect to after a - user successfully registers. -* :attr:`SECURITY_POST_CONFIRM_VIEW`: Specifies the URL to redirect to after a - user successfully confirms their account. -* :attr:`SECURITY_UNAUTHORIZED_VIEW`: Specifies the URL to redirect to when a - user attempts to access a view they don't have permission to view. -* :attr:`SECURITY_DEFAULT_ROLES`: The default roles any new users should have. -* :attr:`SECURITY_CONFIRMABLE`: Enables confirmation features. Defaults to - `False`. -* :attr:`SECURITY_REGISTERABLE`: Enables user registration features. Defaults to - `False`. -* :attr:`SECURITY_RECOVERABLE`: Enables password reset/recovery features. - Defaults to `False`. -* :attr:`SECURITY_TRACKABLE`: Enables login tracking features. Defaults to - `False`. -* :attr:`SECURITY_CONFIRM_EMAIL_WITHIN`: Specifies the amount of time a user - has to confirm their account/email. Default is `5 days`. -* :attr:`SECURITY_RESET_PASSWORD_WITHIN`: Specifies the amount of time a user - has to reset their password. Default is `5 days`. -* :attr:`SECURITY_LOGIN_WITHOUT_CONFIRMATION`: Specifies if users can login - without first confirming their accounts. Defaults to `False` -* :attr:`SECURITY_EMAIL_SENDER`: Specifies the email address to send emails on - behalf of. Defaults to `no-reply@localhost`. -* :attr:`SECURITY_TOKEN_AUTHENTICATION_KEY`: Specifies the query string argument - to use during token authentication. Defaults to `auth_token`. -* :attr:`SECURITY_TOKEN_AUTHENTICATION_HEADER`: Specifies the header name to use - during token authentication. Defaults to `X-Auth-Token`. -* :attr:`SECURITY_CONFIRM_SALT`: Specifies the salt value to use for account - confirmation tokens. Defaults to `confirm-salt`. -* :attr:`SECURITY_RESET_SALT`: Specifies the salt value to use for password - reset tokens. Defaults to `reset-salt`. -* :attr:`SECURITY_AUTH_SALT`: Specifies the salt value to use for token based - authentication tokens. Defaults to `auth-salt`. -* :attr:`SECURITY_DEFAULT_HTTP_AUTH_REALM`: Specifies the default basic HTTP - authentication realm. Defaults to `Login Required`. - - -.. _api: - -API -=== - -.. autoclass:: flask_security.core.Security - :members: - -.. data:: flask_security.core.current_user - - A proxy for the current user. - - -Protecting Views ----------------- -.. autofunction:: flask_security.decorators.login_required - -.. autofunction:: flask_security.decorators.roles_required - -.. autofunction:: flask_security.decorators.roles_accepted - -.. autofunction:: flask_security.decorators.http_auth_required - -.. autofunction:: flask_security.decorators.auth_token_required - - -User Object Helpers -------------------- -.. autoclass:: flask_security.core.UserMixin - :members: - -.. autoclass:: flask_security.core.RoleMixin - :members: - -.. autoclass:: flask_security.core.AnonymousUser - :members: - - -Datastores ----------- -.. autoclass:: flask_security.datastore.UserDatastore - :members: - -.. autoclass:: flask_security.datastore.SQLAlchemyUserDatastore - :members: - :inherited-members: - -.. autoclass:: flask_security.datastore.MongoEngineUserDatastore - :members: - :inherited-members: - - -Exceptions ----------- -.. autoexception:: flask_security.exceptions.BadCredentialsError - -.. autoexception:: flask_security.exceptions.AuthenticationError - -.. autoexception:: flask_security.exceptions.UserNotFoundError - -.. autoexception:: flask_security.exceptions.RoleNotFoundError - -.. autoexception:: flask_security.exceptions.UserIdNotFoundError - -.. autoexception:: flask_security.exceptions.UserDatastoreError - -.. autoexception:: flask_security.exceptions.UserCreationError - -.. autoexception:: flask_security.exceptions.RoleCreationError - -.. autoexception:: flask_security.exceptions.ConfirmationError - -.. autoexception:: flask_security.exceptions.ResetPasswordError - - -Signals -------- -See the documentation for the signals provided by the Flask-Login and -Flask-Principal extensions. Flask-Security does not provide any additional -signals. - - -Changelog -========= - -.. toctree:: - :maxdepth: 2 - - changelog \ No newline at end of file +.. include:: contents.rst.inc \ No newline at end of file diff --git a/docs/models.rst b/docs/models.rst new file mode 100644 index 00000000..4119c01a --- /dev/null +++ b/docs/models.rst @@ -0,0 +1,51 @@ +Models +====== + +Flask-Security assumes you'll be using libraries such as SQLAlchemy or +MongoEngine to define a data model that includes a `User` and `Role` model. The +fields on your models must follow a particular convention depending on the +functionality your app requires. Aside from this, you're free to add any +additional fields to your model(s) if you want. At the bear minimum your `User` +and `Role` model should include the following fields: + +**User** + +* id +* email +* password +* active + +**Role** + +* id +* name +* description + + +Additional Functionality +------------------------ + +Depending on the application's configuration, additional fields may need to be +added to your `User` model. + +Confirmable +^^^^^^^^^^^ + +If you enable account confirmation by setting your application's +`SECURITY_CONFIRMABLE` configuration value to `True` your `User` model will +require the following additional field: + +* confirmed_at + +Trackable +^^^^^^^^^ + +If you enable user tracking by setting your application's `SECURITY_TRACKABLE` +configuration value to `True` your `User` model will require the following +additional fields: + +* last_login_at +* current_login_at +* last_login_ip +* current_login_ip +* login_count \ No newline at end of file diff --git a/docs/overview.rst b/docs/overview.rst new file mode 100644 index 00000000..571a9afe --- /dev/null +++ b/docs/overview.rst @@ -0,0 +1,33 @@ +Overview +======== + +Flask-Security allows you to quickly add common security mechanisms to your +Flask application. They include: + +1. Session based authentication +2. Role management +3. Password encryption +4. Basic HTTP authentication +5. Token based authentication +6. Token based account activation (optional) +7. Token based password recovery/resetting (optional) +8. User registration (optional) +9. Login tracking (optional) + +Many of these features are made possible by integrating various Flask extensions +and libraries. They include: + +1. `Flask-Login `_ +2. `Flask-Mail `_ +3. `Flask-Principal `_ +4. `Flask-Script `_ +5. `Flask-WTF `_ +6. `itsdangerous `_ +7. `passlib `_ + +Additionally, it assumes you'll be using a common library for your database +connections and model definitions. Flask-Security supports the following Flask +extensions out of the box for data persistance: + +1. `Flask-SQLAlchemy `_ +2. `Flask-MongoEngine `_ \ No newline at end of file diff --git a/docs/quickstart.rst b/docs/quickstart.rst new file mode 100644 index 00000000..1aed9908 --- /dev/null +++ b/docs/quickstart.rst @@ -0,0 +1,85 @@ +Quick Start +=========== + + +Installation +------------ + +First, install Flask-Security:: + + $ mkvirtualenv + $ pip install flask-security + +Then install your datastore requirement. + +**SQLAlchemy**:: + + $ pip install flask-sqlalchemy + +**MongoEngine**:: + + $ pip install flask-mongoengine + +And lastly install any password encryption library that you may need. For +example:: + + $ pip install py-bcrypt + + +Application Code +---------------- + +The following code sample illustrates how to get started as quickly as possible +using SQLAlchemy.:: + + from flask import Flask, render_template + from flask.ext.sqlalchemy import SQLAlchemy + from flask.ext.security import Security, SQLAlchemyUserDatastore, \ + UserMixin, RoleMixin + + # Create app + app = Flask(__name__) + app.config['DEBUG'] = True + app.config['SECRET_KEY'] = 'super-secret' + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' + + # Create database connection object + db = SQLAlchemy(app) + + # Define models + roles_users = db.Table('roles_users', + db.Column('user_id', db.Integer(), db.ForeignKey('user.id')), + db.Column('role_id', db.Integer(), db.ForeignKey('role.id'))) + + class Role(db.Model, RoleMixin): + id = db.Column(db.Integer(), primary_key=True) + name = db.Column(db.String(80), unique=True) + description = db.Column(db.String(255)) + + class User(db.Model, UserMixin): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True) + password = db.Column(db.String(255)) + active = db.Column(db.Boolean()) + confirmed_at = db.Column(db.DateTime()) + roles = db.relationship('Role', secondary=roles_users, + backref=db.backref('users', lazy='dynamic')) + + # Setup Flask-Security + user_datastore = SQLAlchemyUserDatastore(db, User, Role) + security = Security(app, user_datastore) + + # Create a user to test with + @app.before_first_request + def create_user(): + db.create_all() + user_datastore.create_user(email='matt@nobien.net', password='password') + db.session.commit() + + # Views + @app.route('/') + def home(): + return render_template('index.html') + + if __name__ == '__main__': + app.run() From 3a07970216b5e0c661ed90ea8093661765cc9b30 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 20 Aug 2012 17:32:13 -0400 Subject: [PATCH 172/234] Fix up salts and get rid of token serializer as its not used --- flask_security/core.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 2927d8a8..4019d1ce 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -34,7 +34,6 @@ 'URL_PREFIX': None, 'FLASH_MESSAGES': True, 'PASSWORD_HASH': 'plaintext', - 'PASSWORD_SALT': None, 'PASSWORD_HMAC': False, 'AUTH_URL': '/auth', 'LOGIN_URL': '/login', @@ -63,9 +62,9 @@ 'EMAIL_SENDER': 'no-reply@localhost', 'TOKEN_AUTHENTICATION_KEY': 'auth_token', 'TOKEN_AUTHENTICATION_HEADER': 'X-Auth-Token', + 'PASSWORD_SALT': None, 'CONFIRM_SALT': 'confirm-salt', 'RESET_SALT': 'reset-salt', - 'AUTH_SALT': 'auth-salt', 'LOGIN_SALT': 'login-salt', 'REMEMBER_SALT': 'remember-salt', 'DEFAULT_HTTP_AUTH_REALM': 'Login Required' @@ -163,10 +162,6 @@ def _get_confirm_serializer(app): return _get_serializer(app, app.config['SECURITY_CONFIRM_SALT']) -def _get_token_auth_serializer(app): - return _get_serializer(app, app.config['SECURITY_AUTH_SALT']) - - def _get_login_serializer(app): return _get_serializer(app, app.config['SECURITY_LOGIN_SALT']) @@ -323,7 +318,6 @@ def _get_state(self, app, datastore): ('principal', _get_principal(app)), ('pwd_context', _get_pwd_context(app)), ('remember_token_serializer', _get_remember_token_serializer(app)), - ('token_auth_serializer', _get_token_auth_serializer(app)), ('context_processors', {})]: kwargs[key] = value From fa4668aa3fe21a284273149a3fafbb2154a64f7a Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 20 Aug 2012 17:44:20 -0400 Subject: [PATCH 173/234] Use default values for encrypt_password and verify_password --- flask_security/core.py | 4 +--- flask_security/datastore.py | 4 +--- flask_security/decorators.py | 4 +--- flask_security/recoverable.py | 4 +--- flask_security/utils.py | 10 ++++++++-- 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 4019d1ce..65f8518c 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -378,9 +378,7 @@ def do_authenticate(self, username_or_email, password): raise exceptions.ConfirmationError('Email requires confirmation.', user) # compare passwords - if verify_password(password, user.password, - salt=_security.password_salt, - use_hmac=_security.password_hmac): + if verify_password(password, user.password): return user # bad match diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 0ba376b6..6cf4ede0 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -95,9 +95,7 @@ def _prepare_create_user_args(self, **kwargs): pw = kwargs['password'] if not pwd_context.identify(pw): - pwd_hash = utils.encrypt_password(pw, - salt=_security.password_salt, - use_hmac=_security.password_hmac) + pwd_hash = utils.encrypt_password(pw) kwargs['password'] = pwd_hash return kwargs diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 7a163f22..66255c79 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -69,9 +69,7 @@ def _check_http_auth(): try: user = _security.datastore.find_user(email=auth.username) - if utils.verify_password(auth.password, user.password, - salt=_security.password_salt, - use_hmac=_security.password_hmac): + if utils.verify_password(auth.password, user.password): identity_changed.send(current_app._get_current_object(), identity=Identity(user.id)) return True diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index d472fc22..54ab50f0 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -79,9 +79,7 @@ def reset_by_token(token, password): data = serializer.loads(token, max_age=max_age) user = _datastore.find_user(id=data[0]) - user.password = encrypt_password(password, - salt=_security.password_salt, - use_hmac=_security.password_hmac) + user.password = encrypt_password(password) _datastore._save_model(user) send_password_reset_notice(user) diff --git a/flask_security/utils.py b/flask_security/utils.py index 6e345346..c0935e6d 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -85,12 +85,18 @@ def get_hmac(msg, salt=None, digestmod=None): return base64.b64encode(hmac.new(salt, msg, digestmod).digest()) -def verify_password(password, password_hash, salt=None, use_hmac=False): +def verify_password(password, password_hash, salt=None, use_hmac=None): + salt = salt or _security.password_salt + if use_hmac is None: + use_hmac = _security.password_hmac hmac_value = get_hmac(password, salt) if use_hmac else password return _pwd_context.verify(hmac_value, password_hash) -def encrypt_password(password, salt=None, use_hmac=False): +def encrypt_password(password, salt=None, use_hmac=None): + salt = salt or _security.password_salt + if use_hmac is None: + use_hmac = _security.password_hmac hmac_value = get_hmac(password, salt) if use_hmac else password return _pwd_context.encrypt(hmac_value) From 332575e53b4d5ca6079e670ce07e421342e9b34a Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 20 Aug 2012 18:12:46 -0400 Subject: [PATCH 174/234] Fix up --- flask_security/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/core.py b/flask_security/core.py index 65f8518c..2434e0e4 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -35,6 +35,7 @@ 'FLASH_MESSAGES': True, 'PASSWORD_HASH': 'plaintext', 'PASSWORD_HMAC': False, + 'PASSWORD_SALT': None, 'AUTH_URL': '/auth', 'LOGIN_URL': '/login', 'LOGOUT_URL': '/logout', @@ -62,7 +63,6 @@ 'EMAIL_SENDER': 'no-reply@localhost', 'TOKEN_AUTHENTICATION_KEY': 'auth_token', 'TOKEN_AUTHENTICATION_HEADER': 'X-Auth-Token', - 'PASSWORD_SALT': None, 'CONFIRM_SALT': 'confirm-salt', 'RESET_SALT': 'reset-salt', 'LOGIN_SALT': 'login-salt', From 101fa42e55952ea74249087cb90ee40713a20f9f Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 20 Aug 2012 18:17:29 -0400 Subject: [PATCH 175/234] Only use password salt if using hmac --- flask_security/utils.py | 18 +++++++++++++----- tests/functional_tests.py | 2 +- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/flask_security/utils.py b/flask_security/utils.py index c0935e6d..eb95c50b 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -85,19 +85,27 @@ def get_hmac(msg, salt=None, digestmod=None): return base64.b64encode(hmac.new(salt, msg, digestmod).digest()) -def verify_password(password, password_hash, salt=None, use_hmac=None): - salt = salt or _security.password_salt +def verify_password(password, password_hash, use_hmac=None): if use_hmac is None: use_hmac = _security.password_hmac - hmac_value = get_hmac(password, salt) if use_hmac else password + + if use_hmac: + hmac_value = get_hmac(password, _security.password_hmac_salt) + else: + hmac_value = password + return _pwd_context.verify(hmac_value, password_hash) def encrypt_password(password, salt=None, use_hmac=None): - salt = salt or _security.password_salt if use_hmac is None: use_hmac = _security.password_hmac - hmac_value = get_hmac(password, salt) if use_hmac else password + + if use_hmac: + hmac_value = get_hmac(password, _security.password_hmac_salt) + else: + hmac_value = password + return _pwd_context.encrypt(hmac_value) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 6adcc84f..2a068855 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -203,7 +203,7 @@ class ConfiguredSecurityTests(SecurityTest): AUTH_CONFIG = { 'SECURITY_PASSWORD_HASH': 'bcrypt', - 'SECURITY_PASSWORD_SALT': 'so-salty', + 'SECURITY_PASSWORD_HMAC_SALT': 'so-salty', 'SECURITY_PASSWORD_HMAC': True, 'SECURITY_REGISTERABLE': True, 'SECURITY_AUTH_URL': '/custom_auth', From ebe34005a114d27f58b34338b4b458e5d47fcfde Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 20 Aug 2012 18:25:03 -0400 Subject: [PATCH 176/234] Update docs --- docs/features.rst | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/features.rst b/docs/features.rst index aa3b64ed..5bdca832 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -37,11 +37,10 @@ Password Encryption ------------------- Password encryption is enabled with `passlib`_. Passwords are stored in plain -text by default but you can easily configure the encryption algorithm and salt -value in your application configuration. You should **always use an encryption -algorithm** in your production environment. Bcrypt is a popular algorithm as -of writing this documentation. Bear in mind passlib does not assume which -algorithm you will choose and may require additional libraries to be installed. +text by default but you can easily configure the encryption algorithm. You +should **always use an encryption algorithm** in your production environment. +You may also specify to use HMAC with a configured salt value in addition to the +algorithm chosen. Bear in mind passlib does not assume which algorithm you will choose and may require additional libraries to be installed. .. basic-http-auth: From bebaac49e305b38bf3554b3287bd8d26d6b645f1 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 20 Aug 2012 23:35:17 -0400 Subject: [PATCH 177/234] Forgo redirecting authentication endpoint so that login form errors can be displayed --- flask_security/core.py | 3 +- flask_security/forms.py | 2 +- flask_security/templates/security/login.html | 2 +- .../templates/security/send_login.html | 2 +- flask_security/views.py | 99 +++++++++---------- tests/__init__.py | 4 +- tests/functional_tests.py | 28 +++--- 7 files changed, 66 insertions(+), 74 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 2434e0e4..b731feb0 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -35,8 +35,7 @@ 'FLASH_MESSAGES': True, 'PASSWORD_HASH': 'plaintext', 'PASSWORD_HMAC': False, - 'PASSWORD_SALT': None, - 'AUTH_URL': '/auth', + 'PASSWORD_HMAC_SALT': None, 'LOGIN_URL': '/login', 'LOGOUT_URL': '/logout', 'REGISTER_URL': '/register', diff --git a/flask_security/forms.py b/flask_security/forms.py index b021fb63..7ab2086e 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -89,7 +89,7 @@ def to_dict(self): return dict(email=self.email.data) -class PasswordlessLoginForm(Form, EmailFormMixin): +class PasswordlessLoginForm(Form, UserEmailFormMixin): """The passwordless login form""" next = HiddenField() diff --git a/flask_security/templates/security/login.html b/flask_security/templates/security/login.html index 35951173..7dd3673b 100644 --- a/flask_security/templates/security/login.html +++ b/flask_security/templates/security/login.html @@ -1,7 +1,7 @@ {% from "security/_macros.html" import render_field_with_errors, render_field %} {% include "security/_messages.html" %}

          Login

          -
          + {{ login_form.hidden_tag() }} {{ render_field_with_errors(login_form.email) }} {{ render_field_with_errors(login_form.password) }} diff --git a/flask_security/templates/security/send_login.html b/flask_security/templates/security/send_login.html index 640f7444..7a2c872a 100644 --- a/flask_security/templates/security/send_login.html +++ b/flask_security/templates/security/send_login.html @@ -1,7 +1,7 @@ {% from "security/_macros.html" import render_field_with_errors, render_field %} {% include "security/_messages.html" %}

          Login

          - + {{ login_form.hidden_tag() }} {{ render_field_with_errors(login_form.email) }} {{ render_field(login_form.next) }} diff --git a/flask_security/views.py b/flask_security/views.py index 4b2a8fd9..6a359c7a 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -74,51 +74,44 @@ def _ctx(endpoint): return _security._run_ctx_processor(endpoint) -def authenticate(): - """View function which handles an authentication request.""" - - form = LoginForm(request.form) +@anonymous_user_required +def login(): + """View function for login view""" + form = LoginForm(request.form, csrf_enabled=not app.testing) user, msg, confirm_url = None, None, None if request.json: - form = LoginForm(MultiDict(request.json)) - - try: - user = _security.auth_provider.authenticate(form) - except ConfirmationError, e: - msg = str(e) - confirm_url = url_for('send_confirmation', email=e.user.email) - except BadCredentialsError, e: - msg = str(e) + form = LoginForm(MultiDict(request.json), csrf_enabled=not app.testing) - if user: - if login_user(user, remember=form.remember.data): - after_this_request(_commit) - if request.json: - return _json_auth_ok(user) - return redirect(get_post_login_redirect()) - msg = get_message('DISABLED_ACCOUNT')[0] + if form.validate_on_submit(): + try: + user = _security.auth_provider.authenticate(form) + except ConfirmationError, e: + msg = str(e) + confirm_url = url_for('send_confirmation', email=e.user.email) + except BadCredentialsError, e: + msg = str(e) - _logger.debug('Unsuccessful authentication attempt: %s' % msg) + if user: + if login_user(user, remember=form.remember.data): + after_this_request(_commit) + if request.json: + return _json_auth_ok(user) + return redirect(get_post_login_redirect()) + msg = get_message('DISABLED_ACCOUNT')[0] - if request.json: - return _json_auth_error(msg) + _logger.debug('Unsuccessful authentication attempt: %s' % msg) - do_flash(msg, 'error') - return redirect(confirm_url or url_for('login')) + if request.json: + return _json_auth_error(msg) + do_flash(msg, 'error') -@anonymous_user_required -def login(): - """View function for login view""" + if confirm_url: + return redirect(confirm_url) - tmp, form = '', LoginForm - - if _security.passwordless: - tmp, form = 'send_', PasswordlessLoginForm - - return render_template('security/%slogin.html' % tmp, - login_form=form(), + return render_template('security/login.html', + login_form=form, **_ctx('login')) @@ -171,16 +164,19 @@ def register(): def send_login(): """View function that sends login instructions for passwordless login""" - form = PasswordlessLoginForm() - user = _datastore.find_user(**form.to_dict()) + form = PasswordlessLoginForm(csrf_enabled=not app.testing) - if user.is_active(): - send_login_instructions(user, form.next.data) - msg = get_message('LOGIN_EMAIL_SENT', email=user.email) - else: - msg = get_message('DISABLED_ACCOUNT') + if form.validate_on_submit(): + user = _datastore.find_user(**form.to_dict()) + + if user.is_active(): + send_login_instructions(user, form.next.data) + msg = get_message('LOGIN_EMAIL_SENT', email=user.email) + else: + msg = get_message('DISABLED_ACCOUNT') + + do_flash(*msg) - do_flash(*msg) return render_template('security/send_login.html', login_form=form, **_ctx('send_login')) @@ -305,20 +301,17 @@ def create_blueprint(app, name, import_name, **kwargs): bp = Blueprint(name, import_name, **kwargs) if config_value('PASSWORDLESS', app=app): - bp.route(config_value('AUTH_URL', app=app), - methods=['POST'], - endpoint='send_login')(send_login) + bp.route(config_value('LOGIN_URL', app=app), + methods=['GET', 'POST'], + endpoint='login')(send_login) - bp.route(config_value('AUTH_URL', app=app) + '/', + bp.route(config_value('LOGIN_URL', app=app) + '/', methods=['GET'], endpoint='token_login')(token_login) else: - bp.route(config_value('AUTH_URL', app=app), - methods=['POST'], - endpoint='authenticate')(authenticate) - - bp.route(config_value('LOGIN_URL', app=app), - endpoint='login')(login) + bp.route(config_value('LOGIN_URL', app=app), + methods=['GET', 'POST'], + endpoint='login')(login) bp.route(config_value('LOGOUT_URL', app=app), endpoint='logout')(logout) diff --git a/tests/__init__.py b/tests/__init__.py index 57a6cb76..88dbf6ea 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -35,7 +35,7 @@ def register(self, email, password='password'): def authenticate(self, email="matt@lp.com", password="password", endpoint=None, **kwargs): data = dict(email=email, password=password, remember='y') - r = self._post(endpoint or '/auth', data=data, **kwargs) + r = self._post(endpoint or '/login', data=data, **kwargs) return r def json_authenticate(self, email="matt@lp.com", password="password", endpoint=None): @@ -45,7 +45,7 @@ def json_authenticate(self, email="matt@lp.com", password="password", endpoint=N "password": "%s" } """ - return self._post(endpoint or '/auth', + return self._post(endpoint or '/login', content_type="application/json", data=data % (email, password)) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 2a068855..e812070d 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -110,9 +110,10 @@ def test_multiple_role_required(self): self.authenticate(user) r = self._get("/admin_and_editor", follow_redirects=True) self.assertIsHomePage(r.data) + self._get('/logout') self.authenticate('dave@lp.com') - r = self._get("/admin_and_editor") + r = self._get("/admin_and_editor", follow_redirects=True) self.assertIn('Admin and Editor Page', r.data) def test_ok_json_auth(self): @@ -206,7 +207,6 @@ class ConfiguredSecurityTests(SecurityTest): 'SECURITY_PASSWORD_HMAC_SALT': 'so-salty', 'SECURITY_PASSWORD_HMAC': True, 'SECURITY_REGISTERABLE': True, - 'SECURITY_AUTH_URL': '/custom_auth', 'SECURITY_LOGOUT_URL': '/custom_logout', 'SECURITY_LOGIN_URL': '/custom_login', 'SECURITY_POST_LOGIN_VIEW': '/post_login', @@ -221,11 +221,11 @@ def test_login_view(self): self.assertIn("

          Login

          ", r.data) def test_authenticate(self): - r = self.authenticate(endpoint="/custom_auth") + r = self.authenticate(endpoint="/custom_login") self.assertIn('Post Login', r.data) def test_logout(self): - self.authenticate(endpoint="/custom_auth") + self.authenticate(endpoint="/custom_login") r = self.logout(endpoint="/custom_logout") self.assertIn('Post Logout', r.data) @@ -484,9 +484,9 @@ class PasswordlessTests(SecurityTest): 'SECURITY_PASSWORDLESS': True, } - def test_login_requset_for_inactive_user(self): + def test_login_request_for_inactive_user(self): msg = self.app.config['SECURITY_MSG_DISABLED_ACCOUNT'][0] - r = self.client.post('/auth', data=dict(email='tiya@lp.com'), follow_redirects=True) + r = self.client.post('/login', data=dict(email='tiya@lp.com'), follow_redirects=True) self.assertIn(msg, r.data) def test_request_login_token_sends_email_and_can_login(self): @@ -495,7 +495,7 @@ def test_request_login_token_sends_email_and_can_login(self): with capture_passwordless_login_requests() as requests: with self.app.mail.record_messages() as outbox: - r = self.client.post('/auth', data=dict(email=e), follow_redirects=True) + r = self.client.post('/login', data=dict(email=e), follow_redirects=True) self.assertEqual(len(outbox), 1) @@ -509,7 +509,7 @@ def test_request_login_token_sends_email_and_can_login(self): msg = self.app.config['SECURITY_MSG_LOGIN_EMAIL_SENT'][0] % dict(email=user.email) self.assertIn(msg, r.data) - r = self.client.get('/auth/' + token, follow_redirects=True) + r = self.client.get('/login/' + token, follow_redirects=True) self.assertIn(self.get_message('PASSWORDLESS_LOGIN_SUCCESSFUL'), r.data) r = self.client.get('/profile') @@ -517,18 +517,18 @@ def test_request_login_token_sends_email_and_can_login(self): def test_invalid_login_token(self): msg = self.app.config['SECURITY_MSG_INVALID_LOGIN_TOKEN'][0] - r = self._get('/auth/bogus', follow_redirects=True) + r = self._get('/login/bogus', follow_redirects=True) self.assertIn(msg, r.data) def test_token_login_forwards_to_post_login_view_when_already_authenticated(self): with capture_passwordless_login_requests() as requests: - self.client.post('/auth', data=dict(email='matt@lp.com'), follow_redirects=True) + self.client.post('/login', data=dict(email='matt@lp.com'), follow_redirects=True) token = requests[0]['login_token'] - r = self.client.get('/auth/' + token, follow_redirects=True) + r = self.client.get('/login/' + token, follow_redirects=True) self.assertIn(self.get_message('PASSWORDLESS_LOGIN_SUCCESSFUL'), r.data) - r = self.client.get('/auth/' + token, follow_redirects=True) + r = self.client.get('/login/' + token, follow_redirects=True) self.assertNotIn(self.get_message('PASSWORDLESS_LOGIN_SUCCESSFUL'), r.data) @@ -543,13 +543,13 @@ def test_expired_login_token_sends_email(self): e = 'matt@lp.com' with capture_passwordless_login_requests() as requests: - self.client.post('/auth', data=dict(email=e), follow_redirects=True) + self.client.post('/login', data=dict(email=e), follow_redirects=True) token = requests[0]['login_token'] time.sleep(3) with self.app.mail.record_messages() as outbox: - r = self.client.get('/auth/' + token, follow_redirects=True) + r = self.client.get('/login/' + token, follow_redirects=True) self.assertEqual(len(outbox), 1) self.assertIn(e, outbox[0].html) From 8c533ff12cbbf9b3bc05ef4ecbda5ccf798542c8 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 20 Aug 2012 23:35:28 -0400 Subject: [PATCH 178/234] docs work --- docs/features.rst | 17 ----------------- docs/index.rst | 2 -- 2 files changed, 19 deletions(-) diff --git a/docs/features.rst b/docs/features.rst index 5bdca832..e870ba25 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -5,8 +5,6 @@ Flask-Security allows you to quickly add common security mechanisms to your Flask application. They include: -.. session-based-auth: - Session Based Authentication ---------------------------- @@ -17,8 +15,6 @@ based on a few of its own configuration values and uses Flask-Login's expired. -.. role-management: - Role/Identity Based Access -------------------------- @@ -31,8 +27,6 @@ and all roles should be uniquely named. This feature is implemented using the control you can refer to the Flask-Princpal `documentation on this topic`_. -.. password-encryption: - Password Encryption ------------------- @@ -43,8 +37,6 @@ You may also specify to use HMAC with a configured salt value in addition to the algorithm chosen. Bear in mind passlib does not assume which algorithm you will choose and may require additional libraries to be installed. -.. basic-http-auth: - Basic HTTP Authentication ------------------------- @@ -53,8 +45,6 @@ This feature expects the incoming authentication information to identify a user in the system. This means that the username must be equal to their email address. -.. token-authentication: - Token Authentication -------------------- @@ -71,8 +61,6 @@ will become invalid. A new token will need to be retrieved using the user's new password. -.. email-confirmation: - Email Confirmation ------------------ @@ -84,7 +72,6 @@ if the user happens to try to use an expired token or has lost the previous email. Confirmation links can be configured to expire after a specified amount of time. -.. password-recovery: Password Reset/Recovery ----------------------- @@ -96,8 +83,6 @@ logged in and can use the new password from then on. Password reset links can be configured to expire after a specified amount of time. -.. user-registration: - User Registration ----------------- @@ -106,8 +91,6 @@ very simple and new users need only supply an email address and their password. This view can be overrided if your registration process requires more fields. -.. login-tracking: - Login Tracking -------------- diff --git a/docs/index.rst b/docs/index.rst index 92747986..32784f31 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,8 +1,6 @@ Flask-Security ============== -.. image:: https://secure.travis-ci.org/mattupstate/flask-security.png?branch=develop - Flask-Security quickly adds security features to your Flask application. From 705b73afc1901ab88a3301af6186c43464fc7611 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 20 Aug 2012 23:40:20 -0400 Subject: [PATCH 179/234] Form refactoring --- flask_security/forms.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/flask_security/forms.py b/flask_security/forms.py index 7ab2086e..c5b9443a 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -21,10 +21,14 @@ _datastore = LocalProxy(lambda: app.extensions['security'].datastore) +email_required = Required(message='Email not provided') +email_validator = Email(message='Invalid email address') + + def unique_user_email(form, field): try: _datastore.find_user(email=field.data) - raise ValidationError('%s is already associated with an account' % field.data) + raise ValidationError(field.data + ' is already associated with an account') except UserNotFoundError: pass @@ -38,21 +42,21 @@ def valid_user_email(form, field): class EmailFormMixin(): email = TextField("Email Address", - validators=[Required(message="Email not provided"), - Email(message="Invalid email address")]) + validators=[email_required, + email_validator]) class UserEmailFormMixin(): email = TextField("Email Address", - validators=[Required(message="Email not provided"), - Email(message="Invalid email address"), + validators=[email_required, + email_validator, valid_user_email]) class UniqueEmailFormMixin(): email = TextField("Email Address", - validators=[Required(message="Email not provided"), - Email(message="Invalid email address"), + validators=[email_required, + email_validator, unique_user_email]) From f2d5028d7c8f220d7b63725ef91325492cf36760 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 21 Aug 2012 00:59:46 -0400 Subject: [PATCH 180/234] Prefer form error messages in some instances --- flask_security/core.py | 1 - flask_security/forms.py | 2 +- flask_security/views.py | 22 +++++++--------------- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index b731feb0..dbb6f3e4 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -43,7 +43,6 @@ 'CONFIRM_URL': '/confirm', 'POST_LOGIN_VIEW': '/', 'POST_LOGOUT_VIEW': '/', - 'POST_FORGOT_VIEW': None, 'CONFIRM_ERROR_VIEW': None, 'POST_REGISTER_VIEW': None, 'POST_CONFIRM_VIEW': None, diff --git a/flask_security/forms.py b/flask_security/forms.py index c5b9443a..1c46c48d 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -20,8 +20,8 @@ # Convenient reference _datastore = LocalProxy(lambda: app.extensions['security'].datastore) - email_required = Required(message='Email not provided') + email_validator = Email(message='Invalid email address') diff --git a/flask_security/views.py b/flask_security/views.py index 6a359c7a..20f429c9 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -91,6 +91,7 @@ def login(): confirm_url = url_for('send_confirmation', email=e.user.email) except BadCredentialsError, e: msg = str(e) + form.password.errors.append(msg) if user: if login_user(user, remember=form.remember.data): @@ -98,16 +99,15 @@ def login(): if request.json: return _json_auth_ok(user) return redirect(get_post_login_redirect()) - msg = get_message('DISABLED_ACCOUNT')[0] + form.email.errors.append(get_message('DISABLED_ACCOUNT')[0]) _logger.debug('Unsuccessful authentication attempt: %s' % msg) if request.json: return _json_auth_error(msg) - do_flash(msg, 'error') - if confirm_url: + do_flash(msg, 'error') return redirect(confirm_url) return render_template('security/login.html', @@ -171,11 +171,9 @@ def send_login(): if user.is_active(): send_login_instructions(user, form.next.data) - msg = get_message('LOGIN_EMAIL_SENT', email=user.email) + do_flash(*get_message('LOGIN_EMAIL_SENT', email=user.email)) else: - msg = get_message('DISABLED_ACCOUNT') - - do_flash(*msg) + form.email.errors.append(get_message('DISABLED_ACCOUNT')[0]) return render_template('security/send_login.html', login_form=form, @@ -249,12 +247,6 @@ def forgot_password(): _logger.debug('%s requested to reset their password' % user) do_flash(*get_message('PASSWORD_RESET_REQUEST', email=user.email)) - if _security.post_forgot_view: - return redirect(get_url(_security.post_forgot_view)) - else: - for key, value in form.errors.items(): - do_flash(value[0], 'error') - return render_template('security/forgot_password.html', forgot_password_form=form, **_ctx('forgot_password')) @@ -264,7 +256,7 @@ def forgot_password(): def reset_password(token): """View function that handles a reset password request.""" - next, msg = None, None + next = None form = ResetPasswordForm(csrf_enabled=not app.testing) if form.validate_on_submit(): @@ -282,7 +274,7 @@ def reset_password(token): email=e.user.email) _logger.debug('Password reset error: ' + msg[0]) - do_flash(*msg) + do_flash(*msg) if next: login_user(user) From 828a9733391975bd8fe2fb6921028b2b22faefb4 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 21 Aug 2012 01:50:40 -0400 Subject: [PATCH 181/234] Add already confirmed scenario. Let datastore work without a request context --- example/app.py | 3 ++- flask_security/core.py | 4 +++- flask_security/datastore.py | 10 +++------- flask_security/forms.py | 3 ++- flask_security/views.py | 10 +++++++--- tests/functional_tests.py | 12 ++++++++++++ 6 files changed, 29 insertions(+), 13 deletions(-) diff --git a/example/app.py b/example/app.py index 985a3c64..6ef31b2e 100644 --- a/example/app.py +++ b/example/app.py @@ -19,6 +19,7 @@ from flask.ext.security.decorators import http_auth_required, \ auth_token_required from flask.ext.security.exceptions import RoleNotFoundError +from flask.ext.security.utils import encrypt_password def create_roles(): @@ -34,7 +35,7 @@ def create_users(): ('jill@lp.com', 'password', ['author'], True), ('tiya@lp.com', 'password', [], False)): current_app.security.datastore.create_user( - email=u[0], password=u[1], roles=u[2], active=u[3]) + email=u[0], password=encrypt_password(u[1]), roles=u[2], active=u[3]) current_app.security.datastore._commit() diff --git a/flask_security/core.py b/flask_security/core.py index dbb6f3e4..d850f176 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -75,6 +75,7 @@ 'EMAIL_CONFIRMED': ('Thank you. Your email has been confirmed.', 'success'), 'ALREADY_CONFIRMED': ('Your email has already been confirmed.', 'info'), 'INVALID_CONFIRMATION_TOKEN': ('Invalid confirmation token.', 'error'), + 'ALREADY_CONFIRMED': ('This email has already been confirmed', 'info'), 'PASSWORD_RESET_REQUEST': ('Instructions to reset your password have been sent to %(email)s.', 'info'), 'PASSWORD_RESET_EXPIRED': ('You did not reset your password within %(within)s. New instructions have been sent to %(email)s.', 'error'), 'INVALID_RESET_PASSWORD_TOKEN': ('Invalid reset password token.', 'error'), @@ -273,6 +274,7 @@ def init_app(self, app, datastore=None, register_blueprint=True): :param app: The application. :param datastore: An instance of a user datastore. """ + datastore = datastore or self.datastore for key, value in _default_config.items(): app.config.setdefault('SECURITY_' + key, value) @@ -290,7 +292,7 @@ def init_app(self, app, datastore=None, register_blueprint=True): template_folder='templates') app.register_blueprint(bp) - state = self._get_state(app, datastore or self.datastore) + state = self._get_state(app, datastore) app.extensions['security'] = state diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 6cf4ede0..06aa014d 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -28,6 +28,9 @@ class UserDatastore(object): :param user_model: A user model class definition :param role_model: A role model class definition """ + pwd_context = None + default_roles = [] + def __init__(self, db, user_model, role_model): self.db = db self.user_model = user_model @@ -82,7 +85,6 @@ def _prepare_role_modify_args(self, user, role): def _prepare_create_user_args(self, **kwargs): kwargs.setdefault('active', True) - kwargs.setdefault('roles', _security.default_roles) roles = kwargs.get('roles', []) for i, role in enumerate(roles): @@ -91,12 +93,6 @@ def _prepare_create_user_args(self, **kwargs): roles[i] = self.find_role(rn) kwargs['roles'] = roles - pwd_context = _security.pwd_context - pw = kwargs['password'] - - if not pwd_context.identify(pw): - pwd_hash = utils.encrypt_password(pw) - kwargs['password'] = pwd_hash return kwargs diff --git a/flask_security/forms.py b/flask_security/forms.py index 1c46c48d..0784d134 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -129,7 +129,8 @@ class RegisterForm(Form, submit = SubmitField("Register") def to_dict(self): - return dict(email=self.email.data, password=self.password.data) + return dict(email=self.email.data, + password=self.password.data) class ResetPasswordForm(Form, diff --git a/flask_security/views.py b/flask_security/views.py index 20f429c9..9aa62706 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -204,9 +204,13 @@ def send_confirmation(): if form.validate_on_submit(): user = _datastore.find_user(**form.to_dict()) - send_confirmation_instructions(user) - _logger.debug('%s request confirmation instructions' % user) - do_flash(*get_message('CONFIRMATION_REQUEST', email=user.email)) + if user.confirmed_at is None: + send_confirmation_instructions(user) + msg = get_message('CONFIRMATION_REQUEST', email=user.email) + _logger.debug('%s request confirmation instructions' % user) + else: + msg = get_message('ALREADY_CONFIRMED') + do_flash(*msg) return render_template('security/send_confirmation.html', reset_confirmation_form=form, diff --git a/tests/functional_tests.py b/tests/functional_tests.py index e812070d..75a07cf3 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -287,6 +287,18 @@ def test_login_before_confirmation(self): r = self.authenticate(email=e) self.assertIn(self.get_message('CONFIRMATION_REQUIRED'), r.data) + def test_send_confirmation_of_already_confirmed_account(self): + e = 'dude@lp.com' + + with capture_registrations() as registrations: + self.register(e) + token = registrations[0]['confirm_token'] + + self.client.get('/confirm/' + token, follow_redirects=True) + self.logout() + r = self.client.post('/confirm', data=dict(email=e)) + self.assertIn(self.get_message('ALREADY_CONFIRMED'), r.data) + def test_register_sends_confirmation_email(self): e = 'dude@lp.com' with self.app.mail.record_messages() as outbox: From 24f02a76f5ba4e2036924900b4442a360e224054 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 21 Aug 2012 01:51:17 -0400 Subject: [PATCH 182/234] Update docs --- docs/configuration.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 24f4e17f..e91974fd 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -32,7 +32,6 @@ Flask-Security configuration options. user successfully confirms their account. * :attr:`SECURITY_UNAUTHORIZED_VIEW`: Specifies the URL to redirect to when a user attempts to access a view they don't have permission to view. -* :attr:`SECURITY_DEFAULT_ROLES`: The default roles any new users should have. * :attr:`SECURITY_CONFIRMABLE`: Enables confirmation features. Defaults to `False`. * :attr:`SECURITY_REGISTERABLE`: Enables user registration features. Defaults to From 66c565a72f56094c4e56d1b485eb4c72ae1ede06 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 21 Aug 2012 11:38:24 -0400 Subject: [PATCH 183/234] Register mail as extension, for now --- example/app.py | 3 ++- tests/functional_tests.py | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/example/app.py b/example/app.py index 6ef31b2e..99ffee6c 100644 --- a/example/app.py +++ b/example/app.py @@ -81,7 +81,8 @@ def create_app(auth_config): app.debug = True app.config['SECRET_KEY'] = 'secret' - app.mail = Mail(app) + mail = Mail(app) + app.extensions['mail'] = mail if auth_config: for key, value in auth_config.items(): diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 75a07cf3..9a46562b 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -301,7 +301,7 @@ def test_send_confirmation_of_already_confirmed_account(self): def test_register_sends_confirmation_email(self): e = 'dude@lp.com' - with self.app.mail.record_messages() as outbox: + with self.app.extensions['mail'].record_messages() as outbox: self.register(e) self.assertEqual(len(outbox), 1) self.assertIn(e, outbox[0].html) @@ -359,7 +359,7 @@ def test_expired_confirmation_token_sends_email(self): time.sleep(3) - with self.app.mail.record_messages() as outbox: + with self.app.extensions['mail'].record_messages() as outbox: r = self.client.get('/confirm/' + token, follow_redirects=True) self.assertEqual(len(outbox), 1) @@ -396,7 +396,7 @@ class RecoverableTests(SecurityTest): def test_forgot_post_sends_email(self): with capture_reset_password_requests(): - with self.app.mail.record_messages() as outbox: + with self.app.extensions['mail'].record_messages() as outbox: self.client.post('/reset', data=dict(email='joe@lp.com')) self.assertEqual(len(outbox), 1) @@ -506,7 +506,7 @@ def test_request_login_token_sends_email_and_can_login(self): r, user, token = None, None, None with capture_passwordless_login_requests() as requests: - with self.app.mail.record_messages() as outbox: + with self.app.extensions['mail'].record_messages() as outbox: r = self.client.post('/login', data=dict(email=e), follow_redirects=True) self.assertEqual(len(outbox), 1) @@ -560,7 +560,7 @@ def test_expired_login_token_sends_email(self): time.sleep(3) - with self.app.mail.record_messages() as outbox: + with self.app.extensions['mail'].record_messages() as outbox: r = self.client.get('/login/' + token, follow_redirects=True) self.assertEqual(len(outbox), 1) From 25e9d02a8ad30307cc00ac469c671592b18ac45e Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 21 Aug 2012 11:52:49 -0400 Subject: [PATCH 184/234] clean up --- 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 06aa014d..aae73676 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -12,7 +12,7 @@ from flask import current_app from werkzeug.local import LocalProxy -from . import exceptions, utils +from . import exceptions # Convenient references _security = LocalProxy(lambda: current_app.extensions['security']) From 58685f2bb4a56bcb1068c4bd1b8e0e199971f096 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 21 Aug 2012 17:04:41 -0400 Subject: [PATCH 185/234] Decent clean up. Get rid of AuthProvider class in favor of keeping it simple --- flask_security/__init__.py | 3 +- flask_security/confirmable.py | 2 +- flask_security/core.py | 54 +--------------------------- flask_security/datastore.py | 67 +---------------------------------- flask_security/forms.py | 7 ++-- flask_security/views.py | 41 +++++++++++---------- 6 files changed, 30 insertions(+), 144 deletions(-) diff --git a/flask_security/__init__.py b/flask_security/__init__.py index e8364fc8..56429a99 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -12,8 +12,7 @@ __version__ = '1.3.0-dev' -from .core import Security, RoleMixin, UserMixin, AnonymousUser, \ - AuthenticationProvider, current_user +from .core import Security, RoleMixin, UserMixin, AnonymousUser, current_user from .datastore import SQLAlchemyUserDatastore, MongoEngineUserDatastore from .decorators import auth_token_required, http_auth_required, \ login_required, roles_accepted, roles_required diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index e8e9bec6..692c99c2 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -55,7 +55,7 @@ def generate_confirmation_token(user): def requires_confirmation(user): """Returns `True` if the user requires confirmation.""" - return user.confirmed_at == None if _security.confirmable else False + return user.confirmed_at == None and _security.confirmable def confirm_by_token(token): diff --git a/flask_security/core.py b/flask_security/core.py index d850f176..cd3ddcc2 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -76,6 +76,7 @@ 'ALREADY_CONFIRMED': ('Your email has already been confirmed.', 'info'), 'INVALID_CONFIRMATION_TOKEN': ('Invalid confirmation token.', 'error'), 'ALREADY_CONFIRMED': ('This email has already been confirmed', 'info'), + 'PASSWORD_MISMATCH': ('Password does not match', 'error'), 'PASSWORD_RESET_REQUEST': ('Instructions to reset your password have been sent to %(email)s.', 'info'), 'PASSWORD_RESET_EXPIRED': ('You did not reset your password within %(within)s. New instructions have been sent to %(email)s.', 'error'), 'INVALID_RESET_PASSWORD_TOKEN': ('Invalid reset password token.', 'error'), @@ -313,7 +314,6 @@ def _get_state(self, app, datastore): for key, value in [ ('app', app), ('datastore', datastore), - ('auth_provider', AuthenticationProvider()), ('login_manager', _get_login_manager(app)), ('principal', _get_principal(app)), ('pwd_context', _get_pwd_context(app)), @@ -331,55 +331,3 @@ def _get_state(self, app, datastore): def __getattr__(self, name): return getattr(self._state, name, None) - - -class AuthenticationProvider(object): - """The default authentication provider implementation.""" - def _get_user(self, username_or_email): - datastore = _security.datastore - - try: - return datastore.find_user(email=username_or_email) - except exceptions.UserNotFoundError: - try: - return datastore.find_user(username=username_or_email) - except: - raise exceptions.UserNotFoundError() - - def authenticate(self, form): - """Processes an authentication request and returns a user instance if - authentication is successful. - - :param form: A populated WTForm instance that contains `email` and - `password` form fields - """ - if not form.validate(): - if form.email.errors: - raise exceptions.BadCredentialsError(form.email.errors[0]) - if form.password.errors: - raise exceptions.BadCredentialsError(form.password.errors[0]) - - return self.do_authenticate(form.email.data, form.password.data) - - def do_authenticate(self, username_or_email, password): - """Returns the authenticated user if authentication is successfull. If - authentication fails an appropriate `AuthenticationError` is raised - - :param username_or_email: The username or email address of the user - :param password: The password supplied by the authentication request - """ - - try: - user = self._get_user(username_or_email) - except exceptions.UserNotFoundError: - raise exceptions.BadCredentialsError('Specified user does not exist.') - - if requires_confirmation(user): - raise exceptions.ConfirmationError('Email requires confirmation.', user) - - # compare passwords - if verify_password(password, user.password): - return user - - # bad match - raise exceptions.BadCredentialsError("Password does not match") diff --git a/flask_security/datastore.py b/flask_security/datastore.py index aae73676..41f21568 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -9,14 +9,8 @@ :license: MIT, see LICENSE for more details. """ -from flask import current_app -from werkzeug.local import LocalProxy - from . import exceptions -# Convenient references -_security = LocalProxy(lambda: current_app.extensions['security']) - class UserDatastore(object): """Abstracted user datastore. Always extend this class and implement the @@ -177,42 +171,8 @@ def activate_user(self, user): class SQLAlchemyUserDatastore(UserDatastore): """A SQLAlchemy datastore implementation for Flask-Security that assumes the use of the Flask-SQLAlchemy extension. - - Example usage:: - - from flask import Flask - from flask.ext.security import Security, SQLAlchemyUserDatastore - from flask.ext.sqlalchemy import SQLAlchemy - - app = Flask(__name__) - app.config['SECRET_KEY'] = 'secret' - app.config['SQLALCHEMY_DATABASE_URI'] = \ - 'sqlite:////tmp/flask_security_example.sqlite' - - db = SQLAlchemy(app) - - roles_users = db.Table('roles_users', - db.Column('user_id', db.Integer(), db.ForeignKey('role.id')), - db.Column('role_id', db.Integer(), db.ForeignKey('user.id'))) - - class Role(db.Model, RoleMixin): - id = db.Column(db.Integer(), primary_key=True) - name = db.Column(db.String(80), unique=True) - - class User(db.Model, UserMixin): - id = db.Column(db.Integer, primary_key=True) - email = db.Column(db.String(255), unique=True) - password = db.Column(db.String(120)) - first_name = db.Column(db.String(120)) - last_name = db.Column(db.String(120)) - active = db.Column(db.Boolean()) - created_at = db.Column(db.DateTime()) - modified_at = db.Column(db.DateTime()) - roles = db.relationship('Role', secondary=roles_users, - backref=db.backref('users', lazy='dynamic')) - - Security(app, SQLAlchemyUserDatastore(db, User, Role)) """ + def _commit(self, *args, **kwargs): self.db.session.commit() @@ -233,31 +193,6 @@ def _do_find_role(self, role): class MongoEngineUserDatastore(UserDatastore): """A MongoEngine datastore implementation for Flask-Security that assumes the use of the Flask-MongoEngine extension. - - Example usage:: - - from flask import Flask - from flask.ext.mongoengine import MongoEngine - from flask.ext.security import Security, MongoEngineUserDatastore - - app = Flask(__name__) - app.config['SECRET_KEY'] = 'secret' - app.config['MONGODB_DB'] = 'flask_security_example' - app.config['MONGODB_HOST'] = 'localhost' - app.config['MONGODB_PORT'] = 27017 - - db = MongoEngine(app) - - class Role(db.Document, RoleMixin): - name = db.StringField(required=True, unique=True, max_length=80) - - class User(db.Document, UserMixin): - email = db.StringField(unique=True, max_length=255) - password = db.StringField(required=True, max_length=120) - active = db.BooleanField(default=True) - roles = db.ListField(db.ReferenceField(Role), default=[]) - - Security(app, MongoEngineUserDatastore(db, User, Role)) """ def _save_model(self, model): diff --git a/flask_security/forms.py b/flask_security/forms.py index 0784d134..607645fb 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -35,9 +35,9 @@ def unique_user_email(form, field): def valid_user_email(form, field): try: - _datastore.find_user(email=field.data) + form.user = _datastore.find_user(email=field.data) except UserNotFoundError: - raise ValidationError('Invalid email address') + raise ValidationError('Specified user does not exist') class EmailFormMixin(): @@ -47,6 +47,7 @@ class EmailFormMixin(): class UserEmailFormMixin(): + user = None email = TextField("Email Address", validators=[email_required, email_validator, @@ -108,7 +109,7 @@ def to_dict(self): return dict(email=self.email.data) -class LoginForm(Form, EmailFormMixin, PasswordFormMixin): +class LoginForm(Form, UserEmailFormMixin, PasswordFormMixin): """The default login form""" remember = BooleanField("Remember Me") diff --git a/flask_security/views.py b/flask_security/views.py index 9aa62706..83f8a581 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -15,10 +15,10 @@ from werkzeug.local import LocalProxy from flask_security.confirmable import confirm_by_token, \ - send_confirmation_instructions + send_confirmation_instructions, requires_confirmation from flask_security.decorators import login_required -from flask_security.exceptions import ConfirmationError, BadCredentialsError, \ - ResetPasswordError, PasswordlessLoginError +from flask_security.exceptions import ConfirmationError, ResetPasswordError, \ + PasswordlessLoginError from flask_security.forms import LoginForm, RegisterForm, ForgotPasswordForm, \ ResetPasswordForm, SendConfirmationForm, PasswordlessLoginForm from flask_security.passwordless import send_login_instructions, login_by_token @@ -27,7 +27,7 @@ from flask_security.signals import user_registered from flask_security.utils import get_url, get_post_login_redirect, do_flash, \ get_message, config_value, login_user, logout_user, \ - anonymous_user_required, url_for_security as url_for + anonymous_user_required, url_for_security as url_for, verify_password # Convenient references @@ -77,37 +77,40 @@ def _ctx(endpoint): @anonymous_user_required def login(): """View function for login view""" - form = LoginForm(request.form, csrf_enabled=not app.testing) + user, msg, confirm_url = None, None, None + form = LoginForm(request.form, csrf_enabled=not app.testing) if request.json: form = LoginForm(MultiDict(request.json), csrf_enabled=not app.testing) if form.validate_on_submit(): - try: - user = _security.auth_provider.authenticate(form) - except ConfirmationError, e: - msg = str(e) - confirm_url = url_for('send_confirmation', email=e.user.email) - except BadCredentialsError, e: - msg = str(e) - form.password.errors.append(msg) - - if user: + user = form.user + + if requires_confirmation(user): + msg = get_message('CONFIRMATION_REQUIRED') + confirm_url = url_for('send_confirmation', email=user.email) + form.email.errors.append(msg[0]) + + elif verify_password(form.password.data, user.password): if login_user(user, remember=form.remember.data): after_this_request(_commit) if request.json: return _json_auth_ok(user) return redirect(get_post_login_redirect()) - form.email.errors.append(get_message('DISABLED_ACCOUNT')[0]) + msg = get_message('DISABLED_ACCOUNT') + form.email.errors.append(msg[0]) + else: + msg = get_message('PASSWORD_MISMATCH') + form.password.errors.append(msg[0]) - _logger.debug('Unsuccessful authentication attempt: %s' % msg) + _logger.debug('Unsuccessful authentication attempt: %s' % msg[0]) if request.json: - return _json_auth_error(msg) + return _json_auth_error(msg[0]) if confirm_url: - do_flash(msg, 'error') + do_flash(*msg) return redirect(confirm_url) return render_template('security/login.html', From d3c23c1994e08fbb2697fc2398efed9fe67d4a42 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 21 Aug 2012 17:34:38 -0400 Subject: [PATCH 186/234] Polish --- flask_security/recoverable.py | 7 +++++-- flask_security/views.py | 9 ++++----- tests/functional_tests.py | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 54ab50f0..a038f46d 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -88,8 +88,11 @@ def reset_by_token(token, password): except SignatureExpired: sig_okay, data = serializer.loads_unsafe(token) - raise ResetPasswordError('Password reset token expired', - user=_datastore.find_user(id=data[0])) + user = _datastore.find_user(id=data[0]) + msg = get_message('PASSWORD_RESET_EXPIRED', + within=_security.reset_password_within, + email=user.email) + raise ResetPasswordError(msg[0], user=user) except BadSignature: raise ResetPasswordError(get_message('INVALID_RESET_PASSWORD_TOKEN')[0]) diff --git a/flask_security/views.py b/flask_security/views.py index 83f8a581..37d83091 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -79,10 +79,12 @@ def login(): """View function for login view""" user, msg, confirm_url = None, None, None - form = LoginForm(request.form, csrf_enabled=not app.testing) + form_data = request.form if request.json: - form = LoginForm(MultiDict(request.json), csrf_enabled=not app.testing) + form_data = MultiDict(request.json) + + form = LoginForm(form_data, csrf_enabled=not app.testing) if form.validate_on_submit(): user = form.user @@ -276,9 +278,6 @@ def reset_password(token): msg = (str(e), 'error') if e.user: send_reset_password_instructions(e.user) - msg = get_message('PASSWORD_RESET_EXPIRED', - within=_security.reset_password_within, - email=e.user.email) _logger.debug('Password reset error: ' + msg[0]) do_flash(*msg) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 9a46562b..8e67e37d 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -404,7 +404,7 @@ def test_forgot_password_invalid_email(self): r = self.client.post('/reset', data=dict(email='larry@lp.com'), follow_redirects=True) - self.assertIn('Invalid email address', r.data) + self.assertIn("Specified user does not exist", r.data) def test_reset_password_with_valid_token(self): with capture_reset_password_requests() as requests: From eec0e23620c6a36d953e17adfc63312e468bcc9d Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 21 Aug 2012 17:35:19 -0400 Subject: [PATCH 187/234] Remove old test --- tests/functional_tests.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 8e67e37d..5a5d1876 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -429,21 +429,6 @@ def test_reset_password_with_invalid_token(self): self.assertIn(self.get_message('INVALID_RESET_PASSWORD_TOKEN'), r.data) - # def test_reset_password_twice_flashes_invalid_token_msg(self): - # with capture_reset_password_requests() as requests: - # self.client.post('/reset', data=dict(email='joe@lp.com')) - # t = requests[0]['token'] - - # data = { - # 'password': 'newpassword', - # 'password_confirm': 'newpassword' - # } - - # url = '/reset/' + t - # r = self.client.post(url, data=data, follow_redirects=True) - # r = self.client.post(url, data=data, follow_redirects=True) - # self.assertIn(self.get_message('INVALID_RESET_PASSWORD_TOKEN'), r.data) - class ExpiredResetPasswordTest(SecurityTest): From 17416cb5350826fdb9a143d1dec7487527e60938 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 21 Aug 2012 18:55:42 -0400 Subject: [PATCH 188/234] Always encrypt password when creating a user --- flask_security/forms.py | 4 ++-- flask_security/script.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/flask_security/forms.py b/flask_security/forms.py index 607645fb..f23ccba4 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -15,7 +15,7 @@ from werkzeug.local import LocalProxy from .exceptions import UserNotFoundError - +from .utils import encrypt_password # Convenient reference _datastore = LocalProxy(lambda: app.extensions['security'].datastore) @@ -131,7 +131,7 @@ class RegisterForm(Form, def to_dict(self): return dict(email=self.email.data, - password=self.password.data) + password=encrypt_password(self.password.data)) class ResetPasswordForm(Form, diff --git a/flask_security/script.py b/flask_security/script.py index ca01c930..0bf3f167 100644 --- a/flask_security/script.py +++ b/flask_security/script.py @@ -12,7 +12,7 @@ from flask.ext.script import Command, Option, prompt_bool from werkzeug.local import LocalProxy -from flask_security import views +from flask_security import views, utils _datastore = LocalProxy(lambda: current_app.extensions['security'].datastore) @@ -40,6 +40,7 @@ def run(self, **kwargs): # sanitize role input a bit ri = re.sub(r'\s', '', kwargs['roles']) kwargs['roles'] = [] if ri == '' else ri.split(',') + kwargs['password'] = utils.encrypt_password(kwargs['password']) _datastore.create_user(**kwargs) From 2471ba0db8cd3bbf746b19cf117bcc95eac2643b Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 22 Aug 2012 12:00:46 -0400 Subject: [PATCH 189/234] Move example app, which was a bad example, to the tests namespace. Its what it was used for anyway. A better example will be provided later --- example/__init__.py | 0 example/app.py | 276 ------------------ example/manage.py | 19 -- tests/__init__.py | 14 +- tests/functional_tests.py | 3 +- tests/test_app/__init__.py | 162 ++++++++++ tests/test_app/mongoengine.py | 47 +++ tests/test_app/sqlalchemy.py | 52 ++++ .../test_app}/templates/_messages.html | 0 .../test_app}/templates/_nav.html | 0 .../test_app}/templates/index.html | 0 .../test_app}/templates/register.html | 0 .../test_app}/templates/unauthorized.html | 0 tests/unit_tests.py | 1 + 14 files changed, 271 insertions(+), 303 deletions(-) delete mode 100644 example/__init__.py delete mode 100644 example/app.py delete mode 100644 example/manage.py create mode 100644 tests/test_app/__init__.py create mode 100644 tests/test_app/mongoengine.py create mode 100644 tests/test_app/sqlalchemy.py rename {example => tests/test_app}/templates/_messages.html (100%) rename {example => tests/test_app}/templates/_nav.html (100%) rename {example => tests/test_app}/templates/index.html (100%) rename {example => tests/test_app}/templates/register.html (100%) rename {example => tests/test_app}/templates/unauthorized.html (100%) diff --git a/example/__init__.py b/example/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/example/app.py b/example/app.py deleted file mode 100644 index 99ffee6c..00000000 --- a/example/app.py +++ /dev/null @@ -1,276 +0,0 @@ -# -*- coding: utf-8 -*- - -# a little trick so you can run: -# $ python example/app.py -# from the root of the security project -import os -import sys -sys.path.pop(0) -sys.path.insert(0, os.getcwd()) - -from flask import Flask, render_template, current_app -from flask.ext.mail import Mail -from flask.ext.mongoengine import MongoEngine -from flask.ext.sqlalchemy import SQLAlchemy -from flask.ext.security import Security, LoginForm, PasswordlessLoginForm, \ - login_required, roles_required, roles_accepted, UserMixin, RoleMixin -from flask.ext.security.datastore import SQLAlchemyUserDatastore, \ - MongoEngineUserDatastore -from flask.ext.security.decorators import http_auth_required, \ - auth_token_required -from flask.ext.security.exceptions import RoleNotFoundError -from flask.ext.security.utils import encrypt_password - - -def create_roles(): - for role in ('admin', 'editor', 'author'): - current_app.security.datastore.create_role(name=role) - current_app.security.datastore._commit() - - -def create_users(): - for u in (('matt@lp.com', 'password', ['admin'], True), - ('joe@lp.com', 'password', ['editor'], True), - ('dave@lp.com', 'password', ['admin', 'editor'], True), - ('jill@lp.com', 'password', ['author'], True), - ('tiya@lp.com', 'password', [], False)): - current_app.security.datastore.create_user( - email=u[0], password=encrypt_password(u[1]), roles=u[2], active=u[3]) - current_app.security.datastore._commit() - - -def populate_data(): - create_roles() - create_users() - - -def add_ctx_processors(app): - s = app.security - - @s.context_processor - def for_all(): - return dict() - - @s.forgot_password_context_processor - def forgot_password(): - return dict() - - @s.login_context_processor - def login(): - return dict() - - @s.register_context_processor - def register(): - return dict() - - @s.reset_password_context_processor - def reset_password(): - return dict() - - @s.send_confirmation_context_processor - def send_confirmation(): - return dict() - - @s.send_login_context_processor - def send_login(): - return dict() - - -def create_app(auth_config): - app = Flask(__name__) - app.debug = True - app.config['SECRET_KEY'] = 'secret' - - mail = Mail(app) - app.extensions['mail'] = mail - - if auth_config: - for key, value in auth_config.items(): - app.config[key] = value - - @app.route('/') - def index(): - return render_template('index.html', content='Home Page') - - @app.route('/profile') - @login_required - def profile(): - return render_template('index.html', content='Profile Page') - - @app.route('/post_login') - @login_required - def post_login(): - return render_template('index.html', content='Post Login') - - @app.route('/http') - @http_auth_required - def http(): - return render_template('index.html', content='HTTP Authentication') - - @app.route('/http_custom_realm') - @http_auth_required('My Realm') - def http_custom_realm(): - return render_template('index.html', content='HTTP Authentication') - - @app.route('/token') - @auth_token_required - def token(): - return render_template('index.html', content='Token Authentication') - - @app.route('/post_logout') - def post_logout(): - return render_template('index.html', content='Post Logout') - - @app.route('/post_register') - def post_register(): - return render_template('index.html', content='Post Register') - - @app.route('/admin') - @roles_required('admin') - def admin(): - return render_template('index.html', content='Admin Page') - - @app.route('/admin_and_editor') - @roles_required('admin', 'editor') - def admin_and_editor(): - return render_template('index.html', content='Admin and Editor Page') - - @app.route('/admin_or_editor') - @roles_accepted('admin', 'editor') - def admin_or_editor(): - return render_template('index.html', content='Admin or Editor Page') - - @app.route('/unauthorized') - def unauthorized(): - return render_template('unauthorized.html') - - @app.route('/coverage/add_role_to_user') - def add_role_to_user(): - ds = app.security.datastore - u = ds.find_user(email='joe@lp.com') - r = ds.find_role('admin') - ds.add_role_to_user(u, r) - return 'success' - - @app.route('/coverage/remove_role_from_user') - def remove_role_from_user(): - ds = app.security.datastore - u = ds.find_user(email='matt@lp.com') - ds.remove_role_from_user(u, 'admin') - return 'success' - - @app.route('/coverage/deactivate_user') - def deactivate_user(): - ds = app.security.datastore - u = ds.find_user(email='matt@lp.com') - ds.deactivate_user(u) - return 'success' - - @app.route('/coverage/activate_user') - def activate_user(): - ds = app.security.datastore - u = ds.find_user(email='tiya@lp.com') - ds.activate_user(u) - return 'success' - - @app.route('/coverage/invalid_role') - def invalid_role(): - ds = app.security.datastore - try: - ds.find_role('bogus') - except RoleNotFoundError: - return 'success' - - return app - - -def create_sqlalchemy_app(auth_config=None, register_blueprint=True): - app = create_app(auth_config) - app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://root@localhost/flask_security_test' - - db = SQLAlchemy(app) - app.db = db - - roles_users = db.Table('roles_users', - db.Column('user_id', db.Integer(), db.ForeignKey('user.id')), - db.Column('role_id', db.Integer(), db.ForeignKey('role.id'))) - - class Role(db.Model, RoleMixin): - id = db.Column(db.Integer(), primary_key=True) - name = db.Column(db.String(80), unique=True) - description = db.Column(db.String(255)) - - class User(db.Model, UserMixin): - id = db.Column(db.Integer, primary_key=True) - email = db.Column(db.String(255), unique=True) - password = db.Column(db.String(255)) - last_login_at = db.Column(db.DateTime()) - current_login_at = db.Column(db.DateTime()) - last_login_ip = db.Column(db.String(100)) - current_login_ip = db.Column(db.String(100)) - login_count = db.Column(db.Integer) - active = db.Column(db.Boolean()) - confirmed_at = db.Column(db.DateTime()) - roles = db.relationship('Role', secondary=roles_users, - backref=db.backref('users', lazy='dynamic')) - - app.security = Security(app, SQLAlchemyUserDatastore(db, User, Role), - register_blueprint=register_blueprint) - - if not register_blueprint: - from example import security - blueprint = security.create_blueprint(app, 'flask_security', __name__) - app.register_blueprint(blueprint) - - @app.before_first_request - def before_first_request(): - db.drop_all() - db.create_all() - populate_data() - - add_ctx_processors(app) - - return app - - -def create_mongoengine_app(auth_config=None): - app = create_app(auth_config) - app.config['MONGODB_DB'] = 'flask_security_test' - app.config['MONGODB_HOST'] = 'localhost' - app.config['MONGODB_PORT'] = 27017 - - db = MongoEngine(app) - app.db = db - - class Role(db.Document, RoleMixin): - name = db.StringField(required=True, unique=True, max_length=80) - description = db.StringField(max_length=255) - - class User(db.Document, UserMixin): - email = db.StringField(unique=True, max_length=255) - password = db.StringField(required=True, max_length=255) - last_login_at = db.DateTimeField() - current_login_at = db.DateTimeField() - last_login_ip = db.StringField(max_length=100) - current_login_ip = db.StringField(max_length=100) - login_count = db.IntField() - active = db.BooleanField(default=True) - confirmed_at = db.DateTimeField() - roles = db.ListField(db.ReferenceField(Role), default=[]) - - app.security = Security(app, MongoEngineUserDatastore(db, User, Role)) - - @app.before_first_request - def before_first_request(): - User.drop_collection() - Role.drop_collection() - populate_data() - - add_ctx_processors(app) - - return app - -if __name__ == '__main__': - app = create_sqlalchemy_app() - #app = create_mongoengine_app() - app.run() diff --git a/example/manage.py b/example/manage.py deleted file mode 100644 index ea5ff32c..00000000 --- a/example/manage.py +++ /dev/null @@ -1,19 +0,0 @@ -# a little trick so you can run: -# $ python example/app.py -# from the root of the security project -import sys -import os - -sys.path.pop(0) -sys.path.insert(0, os.getcwd()) - -from example import app -from flask.ext.script import Manager -from flask.ext.security.script import CreateUserCommand, GenerateBlueprintCommand - -manager = Manager(app.create_sqlalchemy_app()) -manager.add_command('create_user', CreateUserCommand()) -manager.add_command('generate_blueprint', GenerateBlueprintCommand()) - -if __name__ == "__main__": - manager.run() diff --git a/tests/__init__.py b/tests/__init__.py index 88dbf6ea..bba64ef6 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,6 +1,5 @@ from unittest import TestCase -from example import app - +from tests.test_app.sqlalchemy import create_app class SecurityTest(TestCase): @@ -9,14 +8,15 @@ class SecurityTest(TestCase): def setUp(self): super(SecurityTest, self).setUp() - self.app = self._create_app(self.AUTH_CONFIG or None) - self.app.debug = False - self.app.config['TESTING'] = True + app = self._create_app(self.AUTH_CONFIG or {}) + app.debug = False + app.config['TESTING'] = True - self.client = self.app.test_client() + self.app = app + self.client = app.test_client() def _create_app(self, auth_config): - return app.create_sqlalchemy_app(auth_config) + return create_app(auth_config) def _get(self, route, content_type=None, follow_redirects=None, headers=None): return self.client.get(route, follow_redirects=follow_redirects, diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 5a5d1876..c5af61e0 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -560,7 +560,8 @@ def test_expired_login_token_sends_email(self): class MongoEngineSecurityTests(DefaultSecurityTests): def _create_app(self, auth_config): - return app.create_mongoengine_app(auth_config) + from tests.test_app.mongoengine import create_app + return create_app(auth_config) class DefaultDatastoreTests(SecurityTest): diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py new file mode 100644 index 00000000..52de7a06 --- /dev/null +++ b/tests/test_app/__init__.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- + +from flask import Flask, render_template, current_app +from flask.ext.mail import Mail +from flask.ext.security import login_required, roles_required, roles_accepted +from flask.ext.security.decorators import http_auth_required, \ + auth_token_required +from flask.ext.security.exceptions import RoleNotFoundError +from flask.ext.security.utils import encrypt_password +from werkzeug.local import LocalProxy + +ds = LocalProxy(lambda: current_app.extensions['security'].datastore) + +def create_app(config): + app = Flask(__name__) + app.debug = True + app.config['SECRET_KEY'] = 'secret' + + for key, value in config.items(): + app.config[key] = value + + mail = Mail(app) + app.extensions['mail'] = mail + + @app.route('/') + def index(): + return render_template('index.html', content='Home Page') + + @app.route('/profile') + @login_required + def profile(): + return render_template('index.html', content='Profile Page') + + @app.route('/post_login') + @login_required + def post_login(): + return render_template('index.html', content='Post Login') + + @app.route('/http') + @http_auth_required + def http(): + return render_template('index.html', content='HTTP Authentication') + + @app.route('/http_custom_realm') + @http_auth_required('My Realm') + def http_custom_realm(): + return render_template('index.html', content='HTTP Authentication') + + @app.route('/token') + @auth_token_required + def token(): + return render_template('index.html', content='Token Authentication') + + @app.route('/post_logout') + def post_logout(): + return render_template('index.html', content='Post Logout') + + @app.route('/post_register') + def post_register(): + return render_template('index.html', content='Post Register') + + @app.route('/admin') + @roles_required('admin') + def admin(): + return render_template('index.html', content='Admin Page') + + @app.route('/admin_and_editor') + @roles_required('admin', 'editor') + def admin_and_editor(): + return render_template('index.html', content='Admin and Editor Page') + + @app.route('/admin_or_editor') + @roles_accepted('admin', 'editor') + def admin_or_editor(): + return render_template('index.html', content='Admin or Editor Page') + + @app.route('/unauthorized') + def unauthorized(): + return render_template('unauthorized.html') + + @app.route('/coverage/add_role_to_user') + def add_role_to_user(): + u = ds.find_user(email='joe@lp.com') + r = ds.find_role('admin') + ds.add_role_to_user(u, r) + return 'success' + + @app.route('/coverage/remove_role_from_user') + def remove_role_from_user(): + u = ds.find_user(email='matt@lp.com') + ds.remove_role_from_user(u, 'admin') + return 'success' + + @app.route('/coverage/deactivate_user') + def deactivate_user(): + u = ds.find_user(email='matt@lp.com') + ds.deactivate_user(u) + return 'success' + + @app.route('/coverage/activate_user') + def activate_user(): + u = ds.find_user(email='tiya@lp.com') + ds.activate_user(u) + return 'success' + + @app.route('/coverage/invalid_role') + def invalid_role(): + try: + ds.find_role('bogus') + except RoleNotFoundError: + return 'success' + + return app + +def create_roles(): + for role in ('admin', 'editor', 'author'): + ds.create_role(name=role) + ds._commit() + + +def create_users(): + for u in (('matt@lp.com', 'password', ['admin'], True), + ('joe@lp.com', 'password', ['editor'], True), + ('dave@lp.com', 'password', ['admin', 'editor'], True), + ('jill@lp.com', 'password', ['author'], True), + ('tiya@lp.com', 'password', [], False)): + ds.create_user(email=u[0], password=encrypt_password(u[1]), + roles=u[2], active=u[3]) + ds._commit() + +def populate_data(): + create_roles() + create_users() + +def add_context_processors(s): + @s.context_processor + def for_all(): + return dict() + + @s.forgot_password_context_processor + def forgot_password(): + return dict() + + @s.login_context_processor + def login(): + return dict() + + @s.register_context_processor + def register(): + return dict() + + @s.reset_password_context_processor + def reset_password(): + return dict() + + @s.send_confirmation_context_processor + def send_confirmation(): + return dict() + + @s.send_login_context_processor + def send_login(): + return dict() diff --git a/tests/test_app/mongoengine.py b/tests/test_app/mongoengine.py new file mode 100644 index 00000000..e1efdece --- /dev/null +++ b/tests/test_app/mongoengine.py @@ -0,0 +1,47 @@ + +from flask.ext.mongoengine import MongoEngine +from flask.ext.security import Security, UserMixin, RoleMixin, \ + MongoEngineUserDatastore + +from tests.test_app import create_app as create_base_app, populate_data, \ + add_context_processors + +def create_app(config): + app = create_base_app(config) + + app.config['MONGODB_DB'] = 'flask_security_test' + app.config['MONGODB_HOST'] = 'localhost' + app.config['MONGODB_PORT'] = 27017 + + db = MongoEngine(app) + + class Role(db.Document, RoleMixin): + name = db.StringField(required=True, unique=True, max_length=80) + description = db.StringField(max_length=255) + + class User(db.Document, UserMixin): + email = db.StringField(unique=True, max_length=255) + password = db.StringField(required=True, max_length=255) + last_login_at = db.DateTimeField() + current_login_at = db.DateTimeField() + last_login_ip = db.StringField(max_length=100) + current_login_ip = db.StringField(max_length=100) + login_count = db.IntField() + active = db.BooleanField(default=True) + confirmed_at = db.DateTimeField() + roles = db.ListField(db.ReferenceField(Role), default=[]) + + @app.before_first_request + def before_first_request(): + User.drop_collection() + Role.drop_collection() + populate_data() + + app.security = Security(app, MongoEngineUserDatastore(db, User, Role)) + + add_context_processors(app.security) + + return app + +if __name__ == '__main__': + create_app({}).run() diff --git a/tests/test_app/sqlalchemy.py b/tests/test_app/sqlalchemy.py new file mode 100644 index 00000000..555af759 --- /dev/null +++ b/tests/test_app/sqlalchemy.py @@ -0,0 +1,52 @@ + +from flask.ext.sqlalchemy import SQLAlchemy +from flask.ext.security import Security, UserMixin, RoleMixin, \ + SQLAlchemyUserDatastore + +from tests.test_app import create_app as create_base_app, populate_data, \ + add_context_processors + +def create_app(config): + app = create_base_app(config) + + app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://root@localhost/flask_security_test' + + db = SQLAlchemy(app) + + roles_users = db.Table('roles_users', + db.Column('user_id', db.Integer(), db.ForeignKey('user.id')), + db.Column('role_id', db.Integer(), db.ForeignKey('role.id'))) + + class Role(db.Model, RoleMixin): + id = db.Column(db.Integer(), primary_key=True) + name = db.Column(db.String(80), unique=True) + description = db.Column(db.String(255)) + + class User(db.Model, UserMixin): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True) + password = db.Column(db.String(255)) + last_login_at = db.Column(db.DateTime()) + current_login_at = db.Column(db.DateTime()) + last_login_ip = db.Column(db.String(100)) + current_login_ip = db.Column(db.String(100)) + login_count = db.Column(db.Integer) + active = db.Column(db.Boolean()) + confirmed_at = db.Column(db.DateTime()) + roles = db.relationship('Role', secondary=roles_users, + backref=db.backref('users', lazy='dynamic')) + + @app.before_first_request + def before_first_request(): + db.drop_all() + db.create_all() + populate_data() + + app.security = Security(app, SQLAlchemyUserDatastore(db, User, Role)) + + add_context_processors(app.security) + + return app + +if __name__ == '__main__': + create_app({}).run() diff --git a/example/templates/_messages.html b/tests/test_app/templates/_messages.html similarity index 100% rename from example/templates/_messages.html rename to tests/test_app/templates/_messages.html diff --git a/example/templates/_nav.html b/tests/test_app/templates/_nav.html similarity index 100% rename from example/templates/_nav.html rename to tests/test_app/templates/_nav.html diff --git a/example/templates/index.html b/tests/test_app/templates/index.html similarity index 100% rename from example/templates/index.html rename to tests/test_app/templates/index.html diff --git a/example/templates/register.html b/tests/test_app/templates/register.html similarity index 100% rename from example/templates/register.html rename to tests/test_app/templates/register.html diff --git a/example/templates/unauthorized.html b/tests/test_app/templates/unauthorized.html similarity index 100% rename from example/templates/unauthorized.html rename to tests/test_app/templates/unauthorized.html diff --git a/tests/unit_tests.py b/tests/unit_tests.py index ad0b37d4..44860a47 100644 --- a/tests/unit_tests.py +++ b/tests/unit_tests.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import unittest From 86adcf06534c1cb96fea5b8f4fc38ea016c43a11 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 22 Aug 2012 12:06:21 -0400 Subject: [PATCH 190/234] Fix build --- tests/functional_tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index c5af61e0..70f055a7 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -16,7 +16,6 @@ capture_reset_password_requests, capture_passwordless_login_requests from werkzeug.utils import parse_cookie -from example import app from tests import SecurityTest @@ -590,4 +589,5 @@ def test_invalid_role(self): class MongoEngineDatastoreTests(DefaultDatastoreTests): def _create_app(self, auth_config): - return app.create_mongoengine_app(auth_config) + from tests.test_app.mongoengine import create_app + return create_app(auth_config) From 53257c17a9ee248b12d86c821d457edcccf72007 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 22 Aug 2012 15:15:39 -0400 Subject: [PATCH 191/234] Update `send_mail` api and add welcome email for user registration. Also add security state to template context for emails for more complex template rendering. --- flask_security/confirmable.py | 15 ++++++++++----- flask_security/passwordless.py | 6 +----- flask_security/recoverable.py | 11 ++++------- .../security/email/confirmation_instructions.html | 4 +--- .../security/email/confirmation_instructions.txt | 4 +--- .../templates/security/email/welcome.html | 7 +++++++ .../templates/security/email/welcome.txt | 7 +++++++ flask_security/utils.py | 4 ++-- flask_security/views.py | 13 ++++++++----- tests/functional_tests.py | 1 - 10 files changed, 41 insertions(+), 31 deletions(-) create mode 100644 flask_security/templates/security/email/welcome.html create mode 100644 flask_security/templates/security/email/welcome.txt diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 692c99c2..6ed24ccd 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -26,19 +26,24 @@ _datastore = LocalProxy(lambda: _security.datastore) +def generate_confirmation_link(user): + token = generate_confirmation_token(user) + url = url_for_security('confirm_email', token=token) + return request.url_root[:-1] + url, token + + def send_confirmation_instructions(user): """Sends the confirmation instructions email for the specified user. :param user: The user to send the instructions to :param token: The confirmation token """ - token = generate_confirmation_token(user) - url = url_for_security('confirm_email', token=token) - confirmation_link = request.url_root[:-1] + url - ctx = dict(user=user, confirmation_link=confirmation_link) + + confirmation_link, token = generate_confirmation_link(user) send_mail('Please confirm your email', user.email, - 'confirmation_instructions', ctx) + 'confirmation_instructions', + user=user, confirmation_link=confirmation_link) confirm_instructions_sent.send(user, app=app._get_current_object()) return token diff --git a/flask_security/passwordless.py b/flask_security/passwordless.py index 49394a4b..eb0d22b5 100644 --- a/flask_security/passwordless.py +++ b/flask_security/passwordless.py @@ -32,15 +32,11 @@ def send_login_instructions(user, next): :param token: The login token """ token = generate_login_token(user, next) - url = url_for_security('token_login', token=token) - login_link = request.url_root[:-1] + url - ctx = dict(user=user, login_link=login_link) - send_mail('Login Instructions', user.email, - 'login_instructions', ctx) + 'login_instructions', user=user, login_link=login_link) login_instructions_sent.send(dict(user=user, login_token=token), app=app._get_current_object()) diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index a038f46d..7dce7173 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -34,10 +34,9 @@ def send_reset_password_instructions(user): url = url_for_security('reset_password', token=token) reset_link = request.url_root[:-1] + url - send_mail('Password reset instructions', - user.email, + send_mail('Password reset instructions', user.email, 'reset_instructions', - dict(user=user, reset_link=reset_link)) + user=user, reset_link=reset_link) reset_password_instructions_sent.send(dict(user=user, token=token), app=app._get_current_object()) @@ -48,10 +47,8 @@ def send_password_reset_notice(user): :param user: The user to send the notice to """ - send_mail('Your password has been reset', - user.email, - 'reset_notice', - dict(user=user)) + send_mail('Your password has been reset', user.email, + 'reset_notice', user=user) def generate_reset_password_token(user): diff --git a/flask_security/templates/security/email/confirmation_instructions.html b/flask_security/templates/security/email/confirmation_instructions.html index 92badd04..5082a9a8 100644 --- a/flask_security/templates/security/email/confirmation_instructions.html +++ b/flask_security/templates/security/email/confirmation_instructions.html @@ -1,5 +1,3 @@ -

          Welcome {{ user.email }}!

          - -

          You can confirm your email through the link below:

          +

          Please confirm your email through the link below:

          Confirm my account

          \ No newline at end of file diff --git a/flask_security/templates/security/email/confirmation_instructions.txt b/flask_security/templates/security/email/confirmation_instructions.txt index e6a4a3a8..fb435b55 100644 --- a/flask_security/templates/security/email/confirmation_instructions.txt +++ b/flask_security/templates/security/email/confirmation_instructions.txt @@ -1,5 +1,3 @@ -Welcome {{ user.email }}! - -You can confirm your email through the link below: +Please confirm your email through the link below: {{ confirmation_link }} \ No newline at end of file diff --git a/flask_security/templates/security/email/welcome.html b/flask_security/templates/security/email/welcome.html new file mode 100644 index 00000000..55eaed61 --- /dev/null +++ b/flask_security/templates/security/email/welcome.html @@ -0,0 +1,7 @@ +

          Welcome {{ user.email }}!

          + +{% if security.confirmable %} +

          You can confirm your email through the link below:

          + +

          Confirm my account

          +{% endif %} \ No newline at end of file diff --git a/flask_security/templates/security/email/welcome.txt b/flask_security/templates/security/email/welcome.txt new file mode 100644 index 00000000..fb6ee5b5 --- /dev/null +++ b/flask_security/templates/security/email/welcome.txt @@ -0,0 +1,7 @@ +Welcome {{ user.email }}! + +{% if security.confirmable %} +You can confirm your email through the link below: + +{{ confirmation_link }} +{% endif %} \ No newline at end of file diff --git a/flask_security/utils.py b/flask_security/utils.py index eb95c50b..2256fbb2 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -230,7 +230,7 @@ def get_within_delta(key, app=None): return timedelta(**{values[1]: int(values[0])}) -def send_mail(subject, recipient, template, context=None): +def send_mail(subject, recipient, template, **context): """Send an email via the Flask-Mail extension. :param subject: Email subject @@ -241,7 +241,7 @@ def send_mail(subject, recipient, template, context=None): from flask.ext.mail import Message mail = current_app.extensions.get('mail') - context = context or {} + context.setdefault('security', _security) msg = Message(subject, sender=_security.email_sender, diff --git a/flask_security/views.py b/flask_security/views.py index 37d83091..ed9b1267 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -14,8 +14,8 @@ from werkzeug.datastructures import MultiDict from werkzeug.local import LocalProxy -from flask_security.confirmable import confirm_by_token, \ - send_confirmation_instructions, requires_confirmation +from flask_security.confirmable import generate_confirmation_link, \ + send_confirmation_instructions, requires_confirmation, confirm_by_token from flask_security.decorators import login_required from flask_security.exceptions import ConfirmationError, ResetPasswordError, \ PasswordlessLoginError @@ -26,7 +26,7 @@ send_reset_password_instructions from flask_security.signals import user_registered from flask_security.utils import get_url, get_post_login_redirect, do_flash, \ - get_message, config_value, login_user, logout_user, \ + get_message, config_value, login_user, logout_user, send_mail, \ anonymous_user_required, url_for_security as url_for, verify_password @@ -142,17 +142,20 @@ def register(): register_user_form=form, **_ctx('register')) - token = None + confirmation_link, token = None, None user = _datastore.create_user(**form.to_dict()) _commit() if _security.confirmable: - token = send_confirmation_instructions(user) + confirmation_link, token = generate_confirmation_link(user) do_flash(*get_message('CONFIRM_REGISTRATION', email=user.email)) user_registered.send(dict(user=user, confirm_token=token), app=app._get_current_object()) + send_mail('Welcome', user.email, 'welcome', + user=user, confirmation_link=confirmation_link) + _logger.debug('User %s registered' % user) if not _security.confirmable or _security.login_without_confirmation: diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 70f055a7..902c692c 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -362,7 +362,6 @@ def test_expired_confirmation_token_sends_email(self): r = self.client.get('/confirm/' + token, follow_redirects=True) self.assertEqual(len(outbox), 1) - self.assertIn(e, outbox[0].html) self.assertNotIn(token, outbox[0].html) expire_text = self.AUTH_CONFIG['SECURITY_CONFIRM_EMAIL_WITHIN'] From 5964a99e57b2484c83a0bacc7d97a6b1e88ff0d6 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 22 Aug 2012 16:37:07 -0400 Subject: [PATCH 192/234] Clean up --- flask_security/core.py | 3 +-- flask_security/datastore.py | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index cd3ddcc2..4ea89970 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -48,7 +48,6 @@ 'POST_CONFIRM_VIEW': None, 'POST_RESET_VIEW': None, 'UNAUTHORIZED_VIEW': None, - 'DEFAULT_ROLES': [], 'CONFIRMABLE': False, 'REGISTERABLE': False, 'RECOVERABLE': False, @@ -60,7 +59,7 @@ 'LOGIN_WITHOUT_CONFIRMATION': False, 'EMAIL_SENDER': 'no-reply@localhost', 'TOKEN_AUTHENTICATION_KEY': 'auth_token', - 'TOKEN_AUTHENTICATION_HEADER': 'X-Auth-Token', + 'TOKEN_AUTHENTICATION_HEADER': 'Authentication-Token', 'CONFIRM_SALT': 'confirm-salt', 'RESET_SALT': 'reset-salt', 'LOGIN_SALT': 'login-salt', diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 41f21568..02199b86 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -22,8 +22,6 @@ class UserDatastore(object): :param user_model: A user model class definition :param role_model: A role model class definition """ - pwd_context = None - default_roles = [] def __init__(self, db, user_model, role_model): self.db = db From dc39eb58c725807ab83c5cdbf809d58a3726101e Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 22 Aug 2012 16:37:17 -0400 Subject: [PATCH 193/234] Fix test --- tests/functional_tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 902c692c..9752f174 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -134,7 +134,7 @@ def test_token_auth_via_header_valid_token(self): r = self.json_authenticate() data = json.loads(r.data) token = data['response']['user']['authentication_token'] - headers = {"X-Auth-Token": token} + headers = {"Authentication-Token": token} r = self._get('/token', headers=headers) self.assertIn('Token Authentication', r.data) @@ -143,7 +143,7 @@ def test_token_auth_via_querystring_invalid_token(self): self.assertEqual(401, r.status_code) def test_token_auth_via_header_invalid_token(self): - r = self._get('/token', headers={"X-Auth-Token": 'X'}) + r = self._get('/token', headers={"Authentication-Token": 'X'}) self.assertEqual(401, r.status_code) def test_http_auth(self): From 8465fc4818616b0b77f22b638145f76ffd5ceca4 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 22 Aug 2012 16:42:32 -0400 Subject: [PATCH 194/234] Add mail context processory --- flask_security/core.py | 3 +++ flask_security/utils.py | 1 + 2 files changed, 4 insertions(+) diff --git a/flask_security/core.py b/flask_security/core.py index 4ea89970..2d4be43b 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -253,6 +253,9 @@ def send_confirmation_context_processor(self, fn): def send_login_context_processor(self, fn): self._add_ctx_processor('send_login', fn) + def mail_context_processor(self, fn): + self._add_ctx_processor('mail', fn) + class Security(object): """The :class:`Security` class initializes the Flask-Security extension. diff --git a/flask_security/utils.py b/flask_security/utils.py index 2256fbb2..a9638773 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -242,6 +242,7 @@ def send_mail(subject, recipient, template, **context): mail = current_app.extensions.get('mail') context.setdefault('security', _security) + context.update(_security._run_ctx_processor('mail')) msg = Message(subject, sender=_security.email_sender, From 68648c299f885eca8a393346a5f488a3f8505669 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 22 Aug 2012 16:55:31 -0400 Subject: [PATCH 195/234] Change some names --- flask_security/templates/security/login.html | 12 ------------ flask_security/templates/security/login_user.html | 12 ++++++++++++ .../security/{register.html => register_user.html} | 0 .../templates/security/send_confirmation.html | 8 ++++---- flask_security/templates/security/send_login.html | 10 +++++----- flask_security/views.py | 10 +++++----- 6 files changed, 26 insertions(+), 26 deletions(-) delete mode 100644 flask_security/templates/security/login.html create mode 100644 flask_security/templates/security/login_user.html rename flask_security/templates/security/{register.html => register_user.html} (100%) diff --git a/flask_security/templates/security/login.html b/flask_security/templates/security/login.html deleted file mode 100644 index 7dd3673b..00000000 --- a/flask_security/templates/security/login.html +++ /dev/null @@ -1,12 +0,0 @@ -{% from "security/_macros.html" import render_field_with_errors, render_field %} -{% include "security/_messages.html" %} -

          Login

          - - {{ login_form.hidden_tag() }} - {{ render_field_with_errors(login_form.email) }} - {{ render_field_with_errors(login_form.password) }} - {{ render_field_with_errors(login_form.remember) }} - {{ render_field(login_form.next) }} - {{ render_field(login_form.submit) }} - -{% include "security/_menu.html" %} \ No newline at end of file diff --git a/flask_security/templates/security/login_user.html b/flask_security/templates/security/login_user.html new file mode 100644 index 00000000..d781ce08 --- /dev/null +++ b/flask_security/templates/security/login_user.html @@ -0,0 +1,12 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %} +

          Login

          +
          + {{ login_user_form.hidden_tag() }} + {{ render_field_with_errors(login_user_form.email) }} + {{ render_field_with_errors(login_user_form.password) }} + {{ render_field_with_errors(login_user_form.remember) }} + {{ render_field(login_user_form.next) }} + {{ render_field(login_user_form.submit) }} +
          +{% include "security/_menu.html" %} \ No newline at end of file diff --git a/flask_security/templates/security/register.html b/flask_security/templates/security/register_user.html similarity index 100% rename from flask_security/templates/security/register.html rename to flask_security/templates/security/register_user.html diff --git a/flask_security/templates/security/send_confirmation.html b/flask_security/templates/security/send_confirmation.html index d4dc2ed3..3e828407 100644 --- a/flask_security/templates/security/send_confirmation.html +++ b/flask_security/templates/security/send_confirmation.html @@ -1,9 +1,9 @@ {% from "security/_macros.html" import render_field_with_errors, render_field %} {% include "security/_messages.html" %}

          Resend confirmation instructions

          -
          - {{ reset_confirmation_form.hidden_tag() }} - {{ render_field_with_errors(reset_confirmation_form.email) }} - {{ render_field(reset_confirmation_form.submit) }} + + {{ send_confirmation_form.hidden_tag() }} + {{ render_field_with_errors(send_confirmation_form.email) }} + {{ render_field(send_confirmation_form.submit) }}
          {% include "security/_menu.html" %} \ No newline at end of file diff --git a/flask_security/templates/security/send_login.html b/flask_security/templates/security/send_login.html index 7a2c872a..2645b977 100644 --- a/flask_security/templates/security/send_login.html +++ b/flask_security/templates/security/send_login.html @@ -1,10 +1,10 @@ {% from "security/_macros.html" import render_field_with_errors, render_field %} {% include "security/_messages.html" %}

          Login

          -
          - {{ login_form.hidden_tag() }} - {{ render_field_with_errors(login_form.email) }} - {{ render_field(login_form.next) }} - {{ render_field(login_form.submit) }} + + {{ send_login_form.hidden_tag() }} + {{ render_field_with_errors(send_login_form.email) }} + {{ render_field(send_login_form.next) }} + {{ render_field(send_login_form.submit) }}
          {% include "security/_menu.html" %} \ No newline at end of file diff --git a/flask_security/views.py b/flask_security/views.py index ed9b1267..e47ea72e 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -115,8 +115,8 @@ def login(): do_flash(*msg) return redirect(confirm_url) - return render_template('security/login.html', - login_form=form, + return render_template('security/login_user.html', + login_user_form=form, **_ctx('login')) @@ -138,7 +138,7 @@ def register(): form = RegisterForm(csrf_enabled=not app.testing) if not form.validate_on_submit(): - return render_template('security/register.html', + return render_template('security/register_user.html', register_user_form=form, **_ctx('register')) @@ -184,7 +184,7 @@ def send_login(): form.email.errors.append(get_message('DISABLED_ACCOUNT')[0]) return render_template('security/send_login.html', - login_form=form, + send_login_form=form, **_ctx('send_login')) @@ -221,7 +221,7 @@ def send_confirmation(): do_flash(*msg) return render_template('security/send_confirmation.html', - reset_confirmation_form=form, + send_confirmation_form=form, **_ctx('send_confirmation')) From 7361114ccb7eb71771d4b44e7f3d1c2105e8ce95 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 22 Aug 2012 17:12:00 -0400 Subject: [PATCH 196/234] Update documentation a bit --- docs/configuration.rst | 231 ++++++++++++++++++++++++++++++----------- docs/contents.rst.inc | 5 +- docs/customizing.rst | 95 +++++++++++++++++ docs/quickstart.rst | 23 +--- 4 files changed, 274 insertions(+), 80 deletions(-) create mode 100644 docs/customizing.rst diff --git a/docs/configuration.rst b/docs/configuration.rst index e91974fd..5e49c770 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1,62 +1,175 @@ Configuration ============= -Flask-Security configuration options. - -* :attr:`SECURITY_URL_PREFIX`: Specifies the URL prefix for the Security - blueprint. -* :attr:`SECURITY_FLASH_MESSAGES`: Specifies wether or not to flash messages - during security mechanisms. -* :attr:`SECURITY_PASSWORD_HASH`: Specifies the encryption method to use. e.g.: - plaintext, bcrypt, etc. -* :attr:`SECURITY_AUTH_URL`: Specifies the URL to to handle authentication. -* :attr:`SECURITY_LOGOUT_URL`: Specifies the URL to process a logout request. -* :attr:`SECURITY_REGISTER_URL`: Specifies the URL for user registrations. -* :attr:`SECURITY_RESET_URL`: Specifies the URL for password resets. -* :attr:`SECURITY_CONFIRM_URL`: Specifies the URL for account confirmations. -* :attr:`SECURITY_LOGIN_VIEW`: Specifies the URL to redirect to when - authentication is required. -* :attr:`SECURITY_CONFIRM_ERROR_VIEW`: Specifies the URL to redirect to when - an confirmation error occurs. -* :attr:`SECURITY_POST_LOGIN_VIEW`: Specifies the URL to redirect to after a - user logins in. -* :attr:`SECURITY_POST_LOGOUT_VIEW`: Specifies the URL to redirect to after a - user logs out. -* :attr:`SECURITY_POST_FORGOT_VIEW`: Specifies the URL to redirect to after a - user requests password reset instructions. -* :attr:`SECURITY_RESET_PASSWORD_ERROR_VIEW`: Specifies the URL to redirect to - after an error occurs during the password reset process. -* :attr:`SECURITY_POST_REGISTER_VIEW`: Specifies the URL to redirect to after a - user successfully registers. -* :attr:`SECURITY_POST_CONFIRM_VIEW`: Specifies the URL to redirect to after a - user successfully confirms their account. -* :attr:`SECURITY_UNAUTHORIZED_VIEW`: Specifies the URL to redirect to when a - user attempts to access a view they don't have permission to view. -* :attr:`SECURITY_CONFIRMABLE`: Enables confirmation features. Defaults to - `False`. -* :attr:`SECURITY_REGISTERABLE`: Enables user registration features. Defaults to - `False`. -* :attr:`SECURITY_RECOVERABLE`: Enables password reset/recovery features. - Defaults to `False`. -* :attr:`SECURITY_TRACKABLE`: Enables login tracking features. Defaults to - `False`. -* :attr:`SECURITY_CONFIRM_EMAIL_WITHIN`: Specifies the amount of time a user - has to confirm their account/email. Default is `5 days`. -* :attr:`SECURITY_RESET_PASSWORD_WITHIN`: Specifies the amount of time a user - has to reset their password. Default is `5 days`. -* :attr:`SECURITY_LOGIN_WITHOUT_CONFIRMATION`: Specifies if users can login - without first confirming their accounts. Defaults to `False` -* :attr:`SECURITY_EMAIL_SENDER`: Specifies the email address to send emails on - behalf of. Defaults to `no-reply@localhost`. -* :attr:`SECURITY_TOKEN_AUTHENTICATION_KEY`: Specifies the query string argument - to use during token authentication. Defaults to `auth_token`. -* :attr:`SECURITY_TOKEN_AUTHENTICATION_HEADER`: Specifies the header name to use - during token authentication. Defaults to `X-Auth-Token`. -* :attr:`SECURITY_CONFIRM_SALT`: Specifies the salt value to use for account - confirmation tokens. Defaults to `confirm-salt`. -* :attr:`SECURITY_RESET_SALT`: Specifies the salt value to use for password - reset tokens. Defaults to `reset-salt`. -* :attr:`SECURITY_AUTH_SALT`: Specifies the salt value to use for token based - authentication tokens. Defaults to `auth-salt`. -* :attr:`SECURITY_DEFAULT_HTTP_AUTH_REALM`: Specifies the default basic HTTP - authentication realm. Defaults to `Login Required`. \ No newline at end of file +The following configuration values are used by Flask-Security: + +Core +-------------- + +.. tabularcolumns:: |p{6.5cm}|p{8.5cm}| + +======================================== ======================================= +``SECURITY_BLUEPRINT_NAME`` Specifies the name for the + Flask-Security blueprint. Defaults to + ``security``. +``SECURITY_URL_PREFIX`` Specifies the URL prefix for the + Flask-Security blueprint. Defaults to + ``None``. +``SECURITY_FLASH_MESSAGES`` Specifies wether or not to flash + messages during security procedures. + Defaults to ``True``. +``SECURITY_PASSWORD_HASH`` Specifies the password hash algorith to + use when encrypting and decrypting + passwords. Recommended values for + production systems are ``bcrypt``, + ``sha512_crypt``, or ``pbkdf2_sha512``. + Defaults to ``plaintext``. +``SECURITY_PASSWORD_HMAC`` Specifies if Flask-Security should also + use HMAC when encrypting and decrypting + passwords. If set to ``True`` be sure + to specify a salt value via the + ``SECURITY_PASSWORD_HMAC_SALT`` + configuration option. Defaults to + ``False``. +``SECURITY_PASSWORD_HMAC_SALT`` Specifies the HMAC salt. Defaults to + ``None``. +``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 + 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_DEFAULT_HTTP_AUTH_REALM`` Specifies the default authentication + realm when using basic HTTP auth. + Defaults to ``Login Required`` +======================================== ======================================= + + +URLs and Views +-------------- + +.. tabularcolumns:: |p{6.5cm}|p{8.5cm}| + +=============================== ================================================ +``SECURITY_LOGIN_URL`` Specifies the login URL. Defaults to ``/login``. +``SECURITY_LOGOUT_URL`` Specifies the logout URL. Defaults to + ``/logout``. +``SECURITY_REGISTER_URL`` Specifies the register URL. Defaults to + ``/register``. +``SECURITY_RESET_URL`` Specifies the password reset URL. Defaults to + ``/reset``. +``SECURITY_CONFIRM_URL`` Specifies the email confirmation URL. Defaults + to ``/confirm``. +``SECURITY_POST_LOGIN_VIEW`` Specifies the default view to redirect to after + a user logs in. This value can be set to a URL + or an endpoint name. Defaults to ``/``. +``SECURITY_POST_LOGOUT_VIEW`` Specifies the default view to redirect to after + a user logs out. This value can be set to a URL + or an endpoint name. Defaults to ``/``. +``SECURITY_CONFIRM_ERROR_VIEW`` Specifies the view to redirect to if a + confirmation error occurs. This value can be set + to a URL or an endpoint name. If this value is + ``None`` the user is presented the default view + to resend a confirmation link. Defaults to + ``None``. +``SECURITY_POST_REGISTER_VIEW`` Specifies the view to redirect to after a user + successfully registers. This value can be set to + a URL or an endpoint name. If this value is + ``None`` the user is redirected to the value of + ``SECURITY_POST_LOGIN_VIEW``. Defaults to + ``None``. +``SECURITY_POST_CONFIRM_VIEW`` Specifies the view to redirect to after a user + successfully confirms their email. This value + can be set to a URL or an endpoint name. If this + value is ``None`` the user is redirected to the + value of ``SECURITY_POST_LOGIN_VIEW``. Defaults + to ``None``. +``SECURITY_POST_RESET_VIEW`` Specifies the view to redirect to after a user + successfully resets their password. This value + can be set to a URL or an endpoint name. If this + value is ``None`` the user is redirected to the + value of ``SECURITY_POST_LOGIN_VIEW``. Defaults to + ``None``. +``SECURITY_UNAUTHORIZED_VIEW`` Specifies the view to redirect to if a user + attempts to access a URL/endpoint that they do + not have permission to access. If this value is + ``None`` the user is presented with a default + HTTP 403 response. Defaults to ``None``. +=============================== ================================================ + + +Feature Flags +------------- + +.. tabularcolumns:: |p{6.5cm}|p{8.5cm}| + +========================= ====================================================== +``SECURITY_CONFIRMABLE`` Specifies if users are required to confirm their email + address when registering a new account. If this value + is `True` Flask-Security creates an endpoint to handle + confirmations and requests to resend confirmation + instructions. The URL for this endpoint is specified + by the ``SECURITY_CONFIRM_URL`` configuration option. + Defaults to ``False``. +``SECURITY_REGISTERABLE`` Specifies if Flask-Security should create a user + registration endpoint. The URL for this endpoint is + specified by the ``SECURITY_REGISTER_URL`` + configuration option. Defaults to ``False``. +``SECURITY_RECOVERABLE`` Specifies if Flask-Security should create a password + reset/recover endpoint. The URL for this endpoint is + specified by the ``SECURITY_RESET_URL`` configuration + 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 + ``False`` +``SECURITY_PASSWORDLESS`` Specifies if Flask-Security should enable the + passwordless login feature. If set to ``True`` users + are not required to enter a password to login but are + sent an email with a login link. This feature is + experimental and should be used with caution. Defaults + to ``False``. +========================= ====================================================== + + +Miscellaneous +------------- + +.. tabularcolumns:: |p{6.5cm}|p{8.5cm}| + +======================================= ======================================== +``SECURITY_CONFIRM_EMAIL_WITHIN`` Specifies the amount of time a user has + before their confirmation link expires. + Always pluralized the time unit for this + value. Defaults to ``5 days``. +``SECURITY_RESET_PASSWORD_WITHIN`` Specifies the amount of time a user has + before their password reset link + expires. Always pluralized the time unit + for this value. Defaults to ``5 days``. +``SECURITY_LOGIN_WITHIN`` Specifies the amount of time a user has + before a login link expires. This is + only used when the passwordless login + feature is enabled. Always pluralized + the time unit for this value. Defaults + to ``1 days``. +``SECURITY_LOGIN_WITHOUT_CONFIRMATION`` Specifies if a user may login before + confirming their email when the value + of ``SECURITY_CONFIRMABLE`` is set to + ``True``. Defaults to ``False``. +``SECURITY_CONFIRM_SALT`` Specifies the salt value when generating + confirmation links/tokens. Defaults to + ``confirm-salt``. +``SECURITY_RESET_SALT`` Specifies the salt value when generating + password reset links/tokens. Defaults to + ``reset-salt``. +``SECURITY_LOGIN_SALT`` Specifies the salt value when generating + login links/tokens. Defaults to + ``login-salt``. +``SECURITY_REMEMBER_SALT`` Specifies the salt value when generating + remember tokens. Remember tokens are + used instead of user ID's as it is more + secure. Defaults to ``remember-salt``. +======================================= ======================================== diff --git a/docs/contents.rst.inc b/docs/contents.rst.inc index f0431f1b..45ff59eb 100644 --- a/docs/contents.rst.inc +++ b/docs/contents.rst.inc @@ -1,5 +1,5 @@ -Documentation -------------- +Contents +-------- .. toctree:: :maxdepth: 1 @@ -9,5 +9,6 @@ Documentation configuration quickstart models + customizing api changelog \ No newline at end of file diff --git a/docs/customizing.rst b/docs/customizing.rst new file mode 100644 index 00000000..ba7c857d --- /dev/null +++ b/docs/customizing.rst @@ -0,0 +1,95 @@ +Customizing Views +================= + +Flask-Security bootstraps your application with various views for handling its +configured features to get you up and running as quick as possible. However, +you'll probably want to change the way these views look to be more in line with +your application's visual design. + + +Views +----- + +Flask-Security is packaged with a default template for each view it presents to +a user. Templates are located within a subfolder named ``security``. The +following is a list of view templates: + +* `security/forgot_password.html` +* `security/login_user.html` +* `security/register_user.html` +* `security/reset_password.html` +* `security/send_confirmation.html` +* `security/send_login.html` + +Overriding these templates is simple: + +1. Create a folder named ``security`` within your application's templates folder +2. Create a template with the same name for the template you wish to override + +Each template is passed a template context object that includes the following, +including the objects/values that are passed to the template by the main +Flask application context processory: + +* ``_form``: A form object for the view +* ``security``: The Flask-Security extension object + +To add more values to the template context you can specify a context processor +for all views or a specific view. For example:: + + security = Security(app, user_datastore) + + # This processor is added to all templates + @security.context_processor + def security_context_processor(): + return dict(hello="world") + + # This processor is added to only the register view + @security.register_context_processor + def security_register_processor(): + return dict(something="else") + +The following is a list of all the available context processor decorators: + +* ``context_processor``: All views +* ``forgot_password_context_processor``: Forgot password view +* ``login_context_processor``: Login view +* ``register_context_processor``: Register view +* ``reset_password_context_processor``: Reset password view +* ``send_confirmation_context_processor``: Send confirmation view +* ``send_login_context_processor``: Send login view + + +Emails +------ + +Flask-Security is also packaged with a default tempalte for each email that it +may send. Templates are located within the subfolder named ``security/mail``. +The following is a list of email templates: + +* `security/mail/confirmation_instructions.html` +* `security/mail/confirmation_instructions.txt` +* `security/mail/login_instructions.html` +* `security/mail/login_instructions.txt` +* `security/mail/reset_instructions.html` +* `security/mail/reset_instructions.txt` +* `security/mail/reset_notice.html` +* `security/mail/reset_notice.txt` +* `security/mail/welcome.html` +* `security/mail/welcome.txt` + +Overriding these templates is simple: + +1. Create a folder named ``security`` within your application's templates folder +2. Create a folder named ``email`` within the ``security`` folder +3. Create a template with the same name for the template you wish to override + +Each template is passed a template context object that includes values for any +links that are required in the email. If you require more values in the +templates you can specify an email context processor. For example:: + + security = Security(app, user_datastore) + + # This processor is added to all emails + @security.email_context_processor + def security_mail_processor(): + return dict(hello="world") \ No newline at end of file diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 1aed9908..ab420320 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -5,29 +5,14 @@ Quick Start Installation ------------ -First, install Flask-Security:: +Install requirements: $ mkvirtualenv - $ pip install flask-security + $ pip install flask-security, flask-sqlalchemy -Then install your datastore requirement. -**SQLAlchemy**:: - - $ pip install flask-sqlalchemy - -**MongoEngine**:: - - $ pip install flask-mongoengine - -And lastly install any password encryption library that you may need. For -example:: - - $ pip install py-bcrypt - - -Application Code ----------------- +Basic Application +----------------- The following code sample illustrates how to get started as quickly as possible using SQLAlchemy.:: From c55993fe883b282b7f39916902d1930cbd8a9e12 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 22 Aug 2012 17:25:40 -0400 Subject: [PATCH 197/234] Update docs --- docs/customizing.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/customizing.rst b/docs/customizing.rst index ba7c857d..d10a522d 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -85,7 +85,8 @@ Overriding these templates is simple: Each template is passed a template context object that includes values for any links that are required in the email. If you require more values in the -templates you can specify an email context processor. For example:: +templates you can specify an email context processor with the +``email_context_processor`` decorator. For example:: security = Security(app, user_datastore) From b65b717fbc380611b93bc28a3feff98a648fbc0e Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 22 Aug 2012 18:01:31 -0400 Subject: [PATCH 198/234] Polish --- tests/test_app/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py index 52de7a06..8ca14d5b 100644 --- a/tests/test_app/__init__.py +++ b/tests/test_app/__init__.py @@ -160,3 +160,7 @@ def send_confirmation(): @s.send_login_context_processor def send_login(): return dict() + + @s.mail_context_processor + def mail(): + return dict() From 1af774dcb707d94b70bdd310880d7aec6d842a2f Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 23 Aug 2012 12:54:46 -0400 Subject: [PATCH 199/234] Remove unused exceptions --- flask_security/exceptions.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/flask_security/exceptions.py b/flask_security/exceptions.py index 06420f46..47be66b9 100644 --- a/flask_security/exceptions.py +++ b/flask_security/exceptions.py @@ -22,12 +22,6 @@ class BadCredentialsError(SecurityError): """ -class AuthenticationError(SecurityError): - """Raised when an authentication attempt fails due to invalid configuration - or an unknown reason. - """ - - class UserNotFoundError(SecurityError): """Raised by a user datastore when there is an attempt to find a user by their identifier, often username or email, and the user is not found. @@ -40,21 +34,6 @@ class RoleNotFoundError(SecurityError): """ -class UserDatastoreError(SecurityError): - """Raised when a user datastore experiences an unexpected error - """ - - -class UserCreationError(SecurityError): - """Raised when an error occurs when creating a user - """ - - -class RoleCreationError(SecurityError): - """Raised when an error occurs when creating a role - """ - - class ConfirmationError(SecurityError): """Raised when an confirmation error occurs """ From b0b09aea4913efcde116ef8cc1da03790ae17912 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 23 Aug 2012 13:01:11 -0400 Subject: [PATCH 200/234] Add ability to define a send_mail_task which could be used to send mails instead of the default flask-mail plugin. Could also be used to send mail asynchronously. Make flask-mail required as well. --- flask_security/core.py | 13 ++++++++----- flask_security/utils.py | 9 +++++++-- setup.py | 1 + tests/functional_tests.py | 19 +++++++++++++++++++ 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 2d4be43b..eb7d5c56 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -210,6 +210,7 @@ class _SecurityState(object): def __init__(self, **kwargs): for key, value in kwargs.items(): setattr(self, key.lower(), value) + self._send_mail_task = None def _add_ctx_processor(self, endpoint, fn): c = self.context_processors @@ -256,6 +257,9 @@ def send_login_context_processor(self, fn): def mail_context_processor(self, fn): self._add_ctx_processor('mail', fn) + def send_mail_task(self, fn): + self._send_mail_task = fn + class Security(object): """The :class:`Security` class initializes the Flask-Security extension. @@ -270,7 +274,7 @@ def __init__(self, app=None, datastore=None, **kwargs): if app is not None and datastore is not None: self._state = self.init_app(app, datastore, **kwargs) - def init_app(self, app, datastore=None, register_blueprint=True): + def init_app(self, app, datastore=None, register_blueprint=True, **kwargs): """Initializes the Flask-Security extension for the specified application and datastore implentation. @@ -295,7 +299,7 @@ def init_app(self, app, datastore=None, register_blueprint=True): template_folder='templates') app.register_blueprint(bp) - state = self._get_state(app, datastore) + state = self._get_state(app, datastore, **kwargs) app.extensions['security'] = state @@ -304,12 +308,10 @@ def init_app(self, app, datastore=None, register_blueprint=True): return state - def _get_state(self, app, datastore): + def _get_state(self, app, datastore, **kwargs): assert app is not None assert datastore is not None - kwargs = {} - for key, value in get_config(app).items(): kwargs[key.lower()] = value @@ -329,6 +331,7 @@ def _get_state(self, app, datastore): _get_reset_serializer(app) if kwargs['recoverable'] else None) kwargs['confirm_serializer'] = ( _get_confirm_serializer(app) if kwargs['confirmable'] else None) + return _SecurityState(**kwargs) def __getattr__(self, name): diff --git a/flask_security/utils.py b/flask_security/utils.py index a9638773..3030290c 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -20,6 +20,7 @@ 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 werkzeug.local import LocalProxy @@ -238,9 +239,7 @@ def send_mail(subject, recipient, template, **context): :param template: The name of the email template :param context: The context to render the template with """ - from flask.ext.mail import Message - mail = current_app.extensions.get('mail') context.setdefault('security', _security) context.update(_security._run_ctx_processor('mail')) @@ -251,6 +250,12 @@ def send_mail(subject, recipient, template, **context): ctx = ('security/email', template) msg.body = render_template('%s/%s.txt' % ctx, **context) msg.html = render_template('%s/%s.html' % ctx, **context) + + if _security._send_mail_task: + _security._send_mail_task(msg) + return + + mail = current_app.extensions.get('mail') mail.send(msg) diff --git a/setup.py b/setup.py index c24e762c..45fa1700 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,7 @@ install_requires=[ 'Flask>=0.9', 'Flask-Login>=0.1.3', + 'Flask-Mail>=0.6.1', 'Flask-Principal>=0.3', 'Flask-WTF>=0.5.4', 'itsdangerous>=0.15', diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 9752f174..48d6e5ca 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -590,3 +590,22 @@ class MongoEngineDatastoreTests(DefaultDatastoreTests): def _create_app(self, auth_config): from tests.test_app.mongoengine import create_app return create_app(auth_config) + + +class AsyncMailTaskTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_RECOVERABLE': True, + } + + def setUp(self): + super(AsyncMailTaskTests, self).setUp() + self.mail_sent = False + + def test_send_email_task_is_called(self): + @self.app.security.send_mail_task + def send_email(msg): + self.mail_sent = True + + self.client.post('/reset', data=dict(email='joe@lp.com')) + self.assertTrue(self.mail_sent) From 57595bbab468a54e6ddc3490bbd779f149dbb5ba Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 23 Aug 2012 14:56:35 -0400 Subject: [PATCH 201/234] Refactor forms and views a bit. Add more validation to forms --- flask_security/forms.py | 83 ++++++++++++----- flask_security/registerable.py | 41 +++++++++ flask_security/views.py | 163 +++++++++++---------------------- tests/__init__.py | 2 +- tests/functional_tests.py | 2 +- 5 files changed, 156 insertions(+), 135 deletions(-) create mode 100644 flask_security/registerable.py diff --git a/flask_security/forms.py b/flask_security/forms.py index f23ccba4..24ed3928 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -11,11 +11,13 @@ from flask import request, current_app as app from flask.ext.wtf import Form, TextField, PasswordField, SubmitField, \ - HiddenField, Required, BooleanField, EqualTo, Email, ValidationError + HiddenField, Required, BooleanField, EqualTo, Email, ValidationError, \ + Length from werkzeug.local import LocalProxy +from .confirmable import requires_confirmation from .exceptions import UserNotFoundError -from .utils import encrypt_password +from .utils import verify_password, get_message # Convenient reference _datastore = LocalProxy(lambda: app.extensions['security'].datastore) @@ -24,6 +26,8 @@ email_validator = Email(message='Invalid email address') +password_required = Required(message="Password not provided") + def unique_user_email(form, field): try: @@ -63,14 +67,27 @@ class UniqueEmailFormMixin(): class PasswordFormMixin(): password = PasswordField("Password", - validators=[Required(message="Password not provided")]) + validators=[password_required]) + +class NewPasswordFormMixin(): + password = PasswordField("Password", + validators=[password_required, + Length(min=6, max=128)]) class PasswordConfirmFormMixin(): password_confirm = PasswordField("Retype Password", validators=[EqualTo('password', message="Passwords do not match")]) +class NextFormMixin(): + next = HiddenField() + + +class RegisterFormMixin(): + submit = SubmitField("Register") + + class SendConfirmationForm(Form, UserEmailFormMixin): """The default forgot password form""" @@ -81,6 +98,14 @@ def __init__(self, *args, **kwargs): if request.method == 'GET': self.email.data = request.args.get('email', None) + def validate(self): + if not super(SendConfirmationForm, self).validate(): + return False + if self.user.confirmed_at is not None: + self.email.errors.append(get_message('ALREADY_CONFIRMED')[0]) + return False + return True + def to_dict(self): return dict(email=self.email.data) @@ -94,10 +119,9 @@ def to_dict(self): return dict(email=self.email.data) -class PasswordlessLoginForm(Form, UserEmailFormMixin): +class PasswordlessLoginForm(Form, UserEmailFormMixin, NextFormMixin): """The passwordless login form""" - next = HiddenField() submit = SubmitField("Send Login Link") def __init__(self, *args, **kwargs): @@ -105,38 +129,55 @@ def __init__(self, *args, **kwargs): if request.method == 'GET': self.next.data = request.args.get('next', None) + def validate(self): + if not super(PasswordlessLoginForm, self).validate(): + return False + if not self.user.is_active(): + self.email.errors.append(get_message('DISABLED_ACCOUNT')[0]) + return False + return True + def to_dict(self): - return dict(email=self.email.data) + return dict(user=self.user, next=self.next.data) -class LoginForm(Form, UserEmailFormMixin, PasswordFormMixin): +class LoginForm(Form, UserEmailFormMixin, PasswordFormMixin, NextFormMixin): """The default login form""" remember = BooleanField("Remember Me") - next = HiddenField() submit = SubmitField("Login") def __init__(self, *args, **kwargs): super(LoginForm, self).__init__(*args, **kwargs) self.next.data = request.args.get('next', None) - -class RegisterForm(Form, - UniqueEmailFormMixin, - PasswordFormMixin, - PasswordConfirmFormMixin): - """The default register form""" - - submit = SubmitField("Register") - + def validate(self): + if not super(LoginForm, self).validate(): + return False + if not verify_password(self.password.data, self.user.password): + self.password.errors.append('Invalid password') + return False + if requires_confirmation(self.user): + self.email.errors.append(get_message('CONFIRMATION_REQUIRED')[0]) + return False + if not self.user.is_active(): + self.email.errors.append(get_message('DISABLED_ACCOUNT')[0]) + return False + return True + + +class ConfirmRegisterForm(Form, RegisterFormMixin, + UniqueEmailFormMixin, NewPasswordFormMixin): def to_dict(self): return dict(email=self.email.data, - password=encrypt_password(self.password.data)) + password=self.password.data) + + +class RegisterForm(ConfirmRegisterForm, PasswordConfirmFormMixin): + pass -class ResetPasswordForm(Form, - PasswordFormMixin, - PasswordConfirmFormMixin): +class ResetPasswordForm(Form, NewPasswordFormMixin, PasswordConfirmFormMixin): """The default reset password form""" submit = SubmitField("Reset Password") diff --git a/flask_security/registerable.py b/flask_security/registerable.py new file mode 100644 index 00000000..fa842d2d --- /dev/null +++ b/flask_security/registerable.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.registerable + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security registerable module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + +from flask import current_app as app +from werkzeug.local import LocalProxy + +from .confirmable import generate_confirmation_link +from .signals import user_registered +from .utils import do_flash, get_message, send_mail, encrypt_password + +# Convenient references +_security = LocalProxy(lambda: app.extensions['security']) + +_datastore = LocalProxy(lambda: _security.datastore) + + +def register_user(**kwargs): + confirmation_link, token = None, None + kwargs['password'] = encrypt_password(kwargs['password']) + user = _datastore.create_user(**kwargs) + _datastore._commit() + + if _security.confirmable: + confirmation_link, token = generate_confirmation_link(user) + do_flash(*get_message('CONFIRM_REGISTRATION', email=user.email)) + + user_registered.send(dict(user=user, confirm_token=token), + app=app._get_current_object()) + + send_mail('Welcome', user.email, 'welcome', + user=user, confirmation_link=confirmation_link) + + return user diff --git a/flask_security/views.py b/flask_security/views.py index e47ea72e..aedc7b6b 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -14,20 +14,21 @@ from werkzeug.datastructures import MultiDict from werkzeug.local import LocalProxy -from flask_security.confirmable import generate_confirmation_link, \ - send_confirmation_instructions, requires_confirmation, confirm_by_token +from flask_security.confirmable import send_confirmation_instructions, \ + confirm_by_token from flask_security.decorators import login_required from flask_security.exceptions import ConfirmationError, ResetPasswordError, \ PasswordlessLoginError -from flask_security.forms import LoginForm, RegisterForm, ForgotPasswordForm, \ - ResetPasswordForm, SendConfirmationForm, PasswordlessLoginForm +from flask_security.forms import LoginForm, ConfirmRegisterForm, RegisterForm, \ + ForgotPasswordForm, ResetPasswordForm, SendConfirmationForm, \ + PasswordlessLoginForm from flask_security.passwordless import send_login_instructions, login_by_token from flask_security.recoverable import reset_by_token, \ send_reset_password_instructions -from flask_security.signals import user_registered +from flask_security.registerable import register_user from flask_security.utils import get_url, get_post_login_redirect, do_flash, \ - get_message, config_value, login_user, logout_user, send_mail, \ - anonymous_user_required, url_for_security as url_for, verify_password + get_message, config_value, login_user, logout_user, \ + anonymous_user_required, url_for_security as url_for # Convenient references @@ -35,34 +36,19 @@ _datastore = LocalProxy(lambda: _security.datastore) -_logger = LocalProxy(lambda: app.logger) +def _render_json(form): + has_errors = len(form.errors) > 0 -def _json_auth_ok(user): - return jsonify({ - "meta": { - "code": 200 - }, - "response": { - "user": { - "id": str(user.id), - "authentication_token": user.get_auth_token() - } - } - }) - + if has_errors: + code = 400 + response = dict(errors=form.errors) + else: + code = 200 + response = dict(user=dict(id=str(form.user.id), + authentication_token=form.user.get_auth_token())) -def _json_auth_error(msg): - resp = jsonify({ - "meta": { - "code": 400 - }, - "response": { - "error": msg - } - }) - resp.status_code = 400 - return resp + return jsonify(dict(meta=dict(code=code), response=response)) def _commit(response=None): @@ -78,7 +64,6 @@ def _ctx(endpoint): def login(): """View function for login view""" - user, msg, confirm_url = None, None, None form_data = request.form if request.json: @@ -87,33 +72,14 @@ def login(): form = LoginForm(form_data, csrf_enabled=not app.testing) if form.validate_on_submit(): - user = form.user - - if requires_confirmation(user): - msg = get_message('CONFIRMATION_REQUIRED') - confirm_url = url_for('send_confirmation', email=user.email) - form.email.errors.append(msg[0]) - - elif verify_password(form.password.data, user.password): - if login_user(user, remember=form.remember.data): - after_this_request(_commit) - if request.json: - return _json_auth_ok(user) - return redirect(get_post_login_redirect()) - msg = get_message('DISABLED_ACCOUNT') - form.email.errors.append(msg[0]) - else: - msg = get_message('PASSWORD_MISMATCH') - form.password.errors.append(msg[0]) - - _logger.debug('Unsuccessful authentication attempt: %s' % msg[0]) - - if request.json: - return _json_auth_error(msg[0]) - - if confirm_url: - do_flash(*msg) - return redirect(confirm_url) + login_user(form.user, remember=form.remember.data) + after_this_request(_commit) + + if not request.json: + return redirect(get_post_login_redirect()) + + if request.json: + return _render_json(form) return render_template('security/login_user.html', login_user_form=form, @@ -125,47 +91,37 @@ def logout(): """View function which handles a logout request.""" logout_user() - _logger.debug('User logged out') - next_url = request.args.get('next', None) - post_logout_url = get_url(_security.post_logout_view) - return redirect(next_url or post_logout_url) + + return redirect(request.args.get('next', None) or + get_url(_security.post_logout_view)) @anonymous_user_required def register(): """View function which handles a registration request.""" - form = RegisterForm(csrf_enabled=not app.testing) - - if not form.validate_on_submit(): - return render_template('security/register_user.html', - register_user_form=form, - **_ctx('register')) - - confirmation_link, token = None, None - user = _datastore.create_user(**form.to_dict()) - _commit() - if _security.confirmable: - confirmation_link, token = generate_confirmation_link(user) - do_flash(*get_message('CONFIRM_REGISTRATION', email=user.email)) + form = ConfirmRegisterForm + else: + form = RegisterForm - user_registered.send(dict(user=user, confirm_token=token), - app=app._get_current_object()) + form = form(csrf_enabled=not app.testing) - send_mail('Welcome', user.email, 'welcome', - user=user, confirmation_link=confirmation_link) + if form.validate_on_submit(): + user = register_user(**form.to_dict()) - _logger.debug('User %s registered' % user) + if not _security.confirmable or _security.login_without_confirmation: + after_this_request(_commit) + login_user(user) - if not _security.confirmable or _security.login_without_confirmation: - after_this_request(_commit) - login_user(user) + post_register_url = get_url(_security.post_register_view) + post_login_url = get_url(_security.post_login_view) - post_register_url = get_url(_security.post_register_view) - post_login_url = get_url(_security.post_login_view) + return redirect(post_register_url or post_login_url) - return redirect(post_register_url or post_login_url) + return render_template('security/register_user.html', + register_user_form=form, + **_ctx('register')) @anonymous_user_required @@ -175,13 +131,8 @@ def send_login(): form = PasswordlessLoginForm(csrf_enabled=not app.testing) if form.validate_on_submit(): - user = _datastore.find_user(**form.to_dict()) - - if user.is_active(): - send_login_instructions(user, form.next.data) - do_flash(*get_message('LOGIN_EMAIL_SENT', email=user.email)) - else: - form.email.errors.append(get_message('DISABLED_ACCOUNT')[0]) + send_login_instructions(**form.to_dict()) + do_flash(*get_message('LOGIN_EMAIL_SENT', email=form.user.email)) return render_template('security/send_login.html', send_login_form=form, @@ -211,14 +162,8 @@ def send_confirmation(): form = SendConfirmationForm(csrf_enabled=not app.testing) if form.validate_on_submit(): - user = _datastore.find_user(**form.to_dict()) - if user.confirmed_at is None: - send_confirmation_instructions(user) - msg = get_message('CONFIRMATION_REQUEST', email=user.email) - _logger.debug('%s request confirmation instructions' % user) - else: - msg = get_message('ALREADY_CONFIRMED') - do_flash(*msg) + send_confirmation_instructions(form.user) + do_flash(*get_message('CONFIRMATION_REQUEST', email=form.user.email)) return render_template('security/send_confirmation.html', send_confirmation_form=form, @@ -232,14 +177,12 @@ def confirm_email(token): try: user = confirm_by_token(token) except ConfirmationError, e: - _logger.debug('Confirmation error: %s' % e) if e.user: send_confirmation_instructions(e.user) do_flash(str(e), 'error') confirm_error_url = get_url(_security.confirm_error_view) return redirect(confirm_error_url or url_for('send_confirmation')) - _logger.debug('%s confirmed their email' % user) do_flash(*get_message('EMAIL_CONFIRMED')) login_user(user, True) post_confirm_url = get_url(_security.post_confirm_view) @@ -254,10 +197,8 @@ def forgot_password(): form = ForgotPasswordForm(csrf_enabled=not app.testing) if form.validate_on_submit(): - user = _datastore.find_user(**form.to_dict()) - send_reset_password_instructions(user) - _logger.debug('%s requested to reset their password' % user) - do_flash(*get_message('PASSWORD_RESET_REQUEST', email=user.email)) + send_reset_password_instructions(form.user) + do_flash(*get_message('PASSWORD_RESET_REQUEST', email=form.user.email)) return render_template('security/forgot_password.html', forgot_password_form=form, @@ -269,7 +210,7 @@ def reset_password(token): """View function that handles a reset password request.""" next = None - form = ResetPasswordForm(csrf_enabled=not app.testing) + form = ResetPasswordForm(reset_token=token, csrf_enabled=not app.testing) if form.validate_on_submit(): try: @@ -281,13 +222,11 @@ def reset_password(token): msg = (str(e), 'error') if e.user: send_reset_password_instructions(e.user) - _logger.debug('Password reset error: ' + msg[0]) do_flash(*msg) if next: login_user(user) - _logger.debug('%s reset their password' % user) return redirect(next) return render_template('security/reset_password.html', diff --git a/tests/__init__.py b/tests/__init__.py index bba64ef6..3392fab8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -30,7 +30,7 @@ def _post(self, route, data=None, content_type=None, follow_redirects=True, head headers=headers) def register(self, email, password='password'): - data = dict(email=email, password=password, password_confirm=password) + data = dict(email=email, password=password) return self.client.post('/register', data=data, follow_redirects=True) def authenticate(self, email="matt@lp.com", password="password", endpoint=None, **kwargs): diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 48d6e5ca..9bb671c2 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -59,7 +59,7 @@ def test_invalid_user(self): def test_bad_password(self): r = self.authenticate(password="bogus") - self.assertIn("Password does not match", r.data) + self.assertIn("Invalid password", r.data) def test_inactive_user(self): r = self.authenticate("tiya@lp.com", "password") From 5a4fb94be3df9577b259a875d3c0deb5dbe37435 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 23 Aug 2012 15:03:13 -0400 Subject: [PATCH 202/234] Make confirm endpoint anonymous only and get rid of invalid test --- flask_security/views.py | 1 + tests/functional_tests.py | 14 -------------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/flask_security/views.py b/flask_security/views.py index aedc7b6b..1d558db7 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -170,6 +170,7 @@ def send_confirmation(): **_ctx('send_confirmation')) +@anonymous_user_required def confirm_email(token): """View function which handles a email confirmation request.""" after_this_request(_commit) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 9bb671c2..4cd6c076 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -317,20 +317,6 @@ def test_confirm_email(self): msg = self.app.config['SECURITY_MSG_EMAIL_CONFIRMED'][0] self.assertIn(msg, r.data) - def test_confirm_email_twice_flashes_already_confirmed_message(self): - e = 'dude@lp.com' - - with capture_registrations() as registrations: - self.register(e) - token = registrations[0]['confirm_token'] - - url = '/confirm/' + token - self.client.get(url, follow_redirects=True) - r = self.client.get(url, follow_redirects=True) - - msg = self.app.config['SECURITY_MSG_ALREADY_CONFIRMED'][0] - self.assertIn(msg, r.data) - def test_invalid_token_when_confirming_email(self): r = self.client.get('/confirm/bogus', follow_redirects=True) self.assertIn('Invalid confirmation token', r.data) From 6e754ed3564d7fa0f346a01b7fb2c17a006628d8 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 23 Aug 2012 17:58:33 -0400 Subject: [PATCH 203/234] Major refactoring. Got rid of exceptions/errors in favor of using simple return values. Update tests to ensure full coverage according to nose coverage plugin --- flask_security/confirmable.py | 55 ++++------- flask_security/core.py | 24 +++-- flask_security/datastore.py | 13 +-- flask_security/decorators.py | 23 ++--- flask_security/forms.py | 31 ++---- flask_security/passwordless.py | 43 +++----- flask_security/recoverable.py | 55 ++++------- .../templates/security/send_login.html | 1 - flask_security/utils.py | 27 ++++- flask_security/views.py | 99 ++++++++++--------- tests/functional_tests.py | 17 ++++ tests/test_app/__init__.py | 6 +- 12 files changed, 177 insertions(+), 217 deletions(-) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 6ed24ccd..32ac958f 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -11,12 +11,10 @@ from datetime import datetime -from itsdangerous import BadSignature, SignatureExpired from flask import current_app as app, request from werkzeug.local import LocalProxy -from .exceptions import ConfirmationError -from .utils import send_mail, get_max_age, md5, get_message, url_for_security +from .utils import send_mail, md5, url_for_security, get_token_status from .signals import user_confirmed, confirm_instructions_sent @@ -42,8 +40,8 @@ def send_confirmation_instructions(user): confirmation_link, token = generate_confirmation_link(user) send_mail('Please confirm your email', user.email, - 'confirmation_instructions', - user=user, confirmation_link=confirmation_link) + 'confirmation_instructions', user=user, + confirmation_link=confirmation_link) confirm_instructions_sent.send(user, app=app._get_current_object()) return token @@ -63,35 +61,22 @@ def requires_confirmation(user): return user.confirmed_at == None and _security.confirmable -def confirm_by_token(token): - """Confirm the user given the specified token. If the token is invalid or - the user is already confirmed a `ConfirmationError` error will be raised. - If the token is expired a `TokenExpiredError` error will be raised. +def confirm_email_token_status(token): + """Returns the expired status, invalid status, and user of a confirmation + token. For example:: - :param token: The user's confirmation token + expired, invalid, user = confirm_email_token_status('...') + + :param token: The confirmation token + """ + return get_token_status(token, 'confirm', 'CONFIRM_EMAIL') + + +def confirm_user(user): + """Confirms the specified user + + :param user: The user to confirm """ - serializer = _security.confirm_serializer - max_age = get_max_age('CONFIRM_EMAIL') - - try: - data = serializer.loads(token, max_age=max_age) - user = _datastore.find_user(id=data[0]) - - if user.confirmed_at: - raise ConfirmationError(get_message('ALREADY_CONFIRMED')[0]) - - user.confirmed_at = datetime.utcnow() - _datastore._save_model(user) - user_confirmed.send(user, app=app._get_current_object()) - return user - - except SignatureExpired: - sig_okay, data = serializer.loads_unsafe(token) - user = _datastore.find_user(id=data[0]) - msg = get_message('CONFIRMATION_EXPIRED', - within=_security.confirm_email_within, - email=user.email)[0] - raise ConfirmationError(msg, user=user) - - except BadSignature: - raise ConfirmationError(get_message('INVALID_CONFIRMATION_TOKEN')[0]) + user.confirmed_at = datetime.utcnow() + _datastore._save_model(user) + user_confirmed.send(user, app=app._get_current_object()) diff --git a/flask_security/core.py b/flask_security/core.py index eb7d5c56..cfa9d0b0 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -19,10 +19,8 @@ from werkzeug.datastructures import ImmutableList from werkzeug.local import LocalProxy -from . import views, exceptions -from .confirmable import requires_confirmation -from .utils import config_value as cv, get_config, verify_password, md5, \ - url_for_security +from .utils import config_value as cv, get_config, md5, url_for_security +from .views import create_blueprint # Convenient references _security = LocalProxy(lambda: current_app.extensions['security']) @@ -92,19 +90,19 @@ def _user_loader(user_id): - try: - return _security.datastore.find_user(id=user_id) - except: - return None + return _security.datastore.find_user(id=user_id) def _token_loader(token): try: data = _security.remember_token_serializer.loads(token) user = _security.datastore.find_user(id=data[0]) - return user if md5(user.password) == data[1] else None + if user and md5(user.password) == data[1]: + return user except: - return None + pass + + return None def _identity_loader(): @@ -294,9 +292,9 @@ def init_app(self, app, datastore=None, register_blueprint=True, **kwargs): if register_blueprint: name = cv('BLUEPRINT_NAME', app=app) url_prefix = cv('URL_PREFIX', app=app) - bp = views.create_blueprint(app, name, __name__, - url_prefix=url_prefix, - template_folder='templates') + bp = create_blueprint(app, name, __name__, + url_prefix=url_prefix, + template_folder='templates') app.register_blueprint(bp) state = self._get_state(app, datastore, **kwargs) diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 02199b86..3d46ca24 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -9,9 +9,6 @@ :license: MIT, see LICENSE for more details. """ -from . import exceptions - - class UserDatastore(object): """Abstracted user datastore. Always extend this class and implement the :attr:`_save_model`, :attr:`_delete_model`, :attr:`_do_find_user`, and @@ -93,20 +90,14 @@ def find_user(self, **kwargs): :param user: User identifier, usually email address """ - user = self._do_find_user(**kwargs) - if user: - return user - raise exceptions.UserNotFoundError('Parameters=%s' % kwargs) + return self._do_find_user(**kwargs) def find_role(self, role): """Returns a role based on its name. :param role: Role name """ - role = self._do_find_role(role) - if role: - return role - raise exceptions.RoleNotFoundError() + return self._do_find_role(role) def create_role(self, **kwargs): """Creates and returns a new role. diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 66255c79..e61b41c3 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -17,7 +17,6 @@ from werkzeug.local import LocalProxy from . import utils -from .exceptions import UserNotFoundError # Convenient references @@ -52,29 +51,25 @@ def _check_token(): header_token = request.headers.get(header_key, None) token = request.args.get(args_key, header_token) serializer = _security.remember_token_serializer - rv = False try: data = serializer.loads(token) user = _security.datastore.find_user(id=data[0]) - rv = utils.md5(user.password) == data[1] + return utils.md5(user.password) == data[1] except: - pass - - return rv + return False def _check_http_auth(): auth = request.authorization or dict(username=None, password=None) + user = _security.datastore.find_user(email=auth.username) - try: - user = _security.datastore.find_user(email=auth.username) - if utils.verify_password(auth.password, user.password): - identity_changed.send(current_app._get_current_object(), - identity=Identity(user.id)) - return True - except UserNotFoundError: - return False + if user and utils.verify_password(auth.password, user.password): + app = current_app._get_current_object() + identity_changed.send(app, identity=Identity(user.id)) + return True + + return False def http_auth_required(realm): diff --git a/flask_security/forms.py b/flask_security/forms.py index 24ed3928..613b99e4 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -16,7 +16,6 @@ from werkzeug.local import LocalProxy from .confirmable import requires_confirmation -from .exceptions import UserNotFoundError from .utils import verify_password, get_message # Convenient reference @@ -30,17 +29,14 @@ def unique_user_email(form, field): - try: - _datastore.find_user(email=field.data) - raise ValidationError(field.data + ' is already associated with an account') - except UserNotFoundError: - pass + if _datastore.find_user(email=field.data) is not None: + raise ValidationError(field.data + + ' is already associated with an account') def valid_user_email(form, field): - try: - form.user = _datastore.find_user(email=field.data) - except UserNotFoundError: + form.user = _datastore.find_user(email=field.data) + if form.user is None: raise ValidationError('Specified user does not exist') @@ -106,28 +102,20 @@ def validate(self): return False return True - def to_dict(self): - return dict(email=self.email.data) - class ForgotPasswordForm(Form, UserEmailFormMixin): """The default forgot password form""" submit = SubmitField("Recover Password") - def to_dict(self): - return dict(email=self.email.data) - -class PasswordlessLoginForm(Form, UserEmailFormMixin, NextFormMixin): +class PasswordlessLoginForm(Form, UserEmailFormMixin): """The passwordless login form""" submit = SubmitField("Send Login Link") def __init__(self, *args, **kwargs): super(PasswordlessLoginForm, self).__init__(*args, **kwargs) - if request.method == 'GET': - self.next.data = request.args.get('next', None) def validate(self): if not super(PasswordlessLoginForm, self).validate(): @@ -137,9 +125,6 @@ def validate(self): return False return True - def to_dict(self): - return dict(user=self.user, next=self.next.data) - class LoginForm(Form, UserEmailFormMixin, PasswordFormMixin, NextFormMixin): """The default login form""" @@ -149,7 +134,6 @@ class LoginForm(Form, UserEmailFormMixin, PasswordFormMixin, NextFormMixin): def __init__(self, *args, **kwargs): super(LoginForm, self).__init__(*args, **kwargs) - self.next.data = request.args.get('next', None) def validate(self): if not super(LoginForm, self).validate(): @@ -181,6 +165,3 @@ class ResetPasswordForm(Form, NewPasswordFormMixin, PasswordConfirmFormMixin): """The default reset password form""" submit = SubmitField("Reset Password") - - def to_dict(self): - return dict(password=self.password.data) diff --git a/flask_security/passwordless.py b/flask_security/passwordless.py index eb0d22b5..7a9e001a 100644 --- a/flask_security/passwordless.py +++ b/flask_security/passwordless.py @@ -10,13 +10,10 @@ """ from flask import request, current_app as app -from itsdangerous import SignatureExpired, BadSignature from werkzeug.local import LocalProxy -from .exceptions import PasswordlessLoginError from .signals import login_instructions_sent -from .utils import send_mail, md5, get_max_age, login_user, get_message, \ - url_for_security, get_url +from .utils import send_mail, url_for_security, get_token_status # Convenient references @@ -25,13 +22,13 @@ _datastore = LocalProxy(lambda: _security.datastore) -def send_login_instructions(user, next): +def send_login_instructions(user): """Sends the login instructions email for the specified user. :param user: The user to send the instructions to :param token: The login token """ - token = generate_login_token(user, next) + token = generate_login_token(user) url = url_for_security('token_login', token=token) login_link = request.url_root[:-1] + url @@ -42,30 +39,20 @@ def send_login_instructions(user, next): app=app._get_current_object()) -def generate_login_token(user, next): - next = next or get_url(_security.post_login_view) - data = [user.id, md5(user.password), next] - return _security.login_serializer.dumps(data) +def generate_login_token(user): + """Generates a unique login token for the specified user. + :param user: The user the token belongs to + """ + return _security.login_serializer.dumps([user.id]) -def login_by_token(token): - serializer = _security.login_serializer - max_age = get_max_age('LOGIN') - try: - user_id, pw, next = serializer.loads(token, max_age=max_age) - user = _datastore.find_user(id=user_id) - login_user(user, True) - return user, next +def login_token_status(token): + """Returns the expired status, invalid status, and user of a login token. + For example:: - except SignatureExpired: - sig_okay, data = serializer.loads_unsafe(token) - user_id, pw, next = data - user = _datastore.find_user(id=data[0]) - within = _security.login_within - msg = get_message('LOGIN_EXPIRED', within=within, email=user.email) - raise PasswordlessLoginError(msg[0], user=user, next=next) + expired, invalid, user = login_token_status('...') - except BadSignature: - msg = get_message('INVALID_LOGIN_TOKEN') - raise PasswordlessLoginError(msg[0]) + :param token: The login token + """ + return get_token_status(token, 'login', 'LOGIN') diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 7dce7173..b9030f79 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -9,14 +9,12 @@ :license: MIT, see LICENSE for more details. """ -from itsdangerous import BadSignature, SignatureExpired from flask import current_app as app, request from werkzeug.local import LocalProxy -from .exceptions import ResetPasswordError from .signals import password_reset, reset_password_instructions_sent -from .utils import send_mail, get_max_age, md5, get_message, encrypt_password, \ - url_for_security +from .utils import send_mail, md5, encrypt_password, url_for_security, \ + get_token_status # Convenient references @@ -60,36 +58,23 @@ def generate_reset_password_token(user): return _security.reset_serializer.dumps(data) -def reset_by_token(token, password): - """Resets the password of the user given the specified token, email and - password. If the token is invalid a `ResetPasswordError` error will be - raised. If the token is expired a `TokenExpiredError` error will be raised. +def reset_password_token_status(token): + """Returns the expired status, invalid status, and user of a password reset + token. For example:: - :param token: The user's reset password token - :param email: The user's email address - :param password: The user's new password + expired, invalid, user = reset_password_token_status('...') + + :param token: The password reset token + """ + return get_token_status(token, 'reset', 'RESET_PASSWORD') + +def update_password(user, password): + """Update the specified user's password + + :param user: The user to update_password + :param password: The unencrypted new password """ - serializer = _security.reset_serializer - max_age = get_max_age('RESET_PASSWORD') - - try: - data = serializer.loads(token, max_age=max_age) - user = _datastore.find_user(id=data[0]) - - user.password = encrypt_password(password) - - _datastore._save_model(user) - send_password_reset_notice(user) - password_reset.send(user, app=app._get_current_object()) - return user - - except SignatureExpired: - sig_okay, data = serializer.loads_unsafe(token) - user = _datastore.find_user(id=data[0]) - msg = get_message('PASSWORD_RESET_EXPIRED', - within=_security.reset_password_within, - email=user.email) - raise ResetPasswordError(msg[0], user=user) - - except BadSignature: - raise ResetPasswordError(get_message('INVALID_RESET_PASSWORD_TOKEN')[0]) + user.password = encrypt_password(password) + _datastore._save_model(user) + send_password_reset_notice(user) + password_reset.send(user, app=app._get_current_object()) diff --git a/flask_security/templates/security/send_login.html b/flask_security/templates/security/send_login.html index 2645b977..15611c57 100644 --- a/flask_security/templates/security/send_login.html +++ b/flask_security/templates/security/send_login.html @@ -4,7 +4,6 @@

          Login

          {{ send_login_form.hidden_tag() }} {{ render_field_with_errors(send_login_form.email) }} - {{ render_field(send_login_form.next) }} {{ render_field(send_login_form.submit) }}
          {% include "security/_menu.html" %} \ No newline at end of file diff --git a/flask_security/utils.py b/flask_security/utils.py index 3030290c..428c53ad 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -22,6 +22,7 @@ logout_user as _logout_user from flask.ext.mail import Message from flask.ext.principal import Identity, AnonymousIdentity, identity_changed +from itsdangerous import BadSignature, SignatureExpired from werkzeug.local import LocalProxy from .core import current_user @@ -51,8 +52,7 @@ def wrapper(*args, **kwargs): def login_user(user, remember=True): """Performs the login and sends the appropriate signal.""" - if not _login_user(user, remember): - return False + _login_user(user, remember) if _security.trackable: old_current, new_current = user.current_login_at, datetime.utcnow() @@ -69,8 +69,6 @@ def login_user(user, remember=True): _datastore._save_model(user) identity_changed.send(current_app._get_current_object(), identity=Identity(user.id)) - _logger.debug('User %s logged in' % user) - return True def logout_user(): @@ -259,6 +257,27 @@ def send_mail(subject, recipient, template, **context): mail.send(msg) +def get_token_status(token, serializer, max_age=None): + serializer = getattr(_security, serializer + '_serializer') + max_age = get_max_age(max_age) + user, data = None, None + expired, invalid = False, False + + try: + data = serializer.loads(token, max_age=max_age) + except SignatureExpired: + d, data = serializer.loads_unsafe(token) + expired = True + except BadSignature: + invalid = True + + if data: + user = _datastore.find_user(id=data[0]) + + expired = expired and (user is not None) + return expired, invalid, user + + @contextmanager def capture_passwordless_login_requests(): login_requests = [] diff --git a/flask_security/views.py b/flask_security/views.py index 1d558db7..e03898c7 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -15,16 +15,15 @@ from werkzeug.local import LocalProxy from flask_security.confirmable import send_confirmation_instructions, \ - confirm_by_token + confirm_user, confirm_email_token_status from flask_security.decorators import login_required -from flask_security.exceptions import ConfirmationError, ResetPasswordError, \ - PasswordlessLoginError from flask_security.forms import LoginForm, ConfirmRegisterForm, RegisterForm, \ ForgotPasswordForm, ResetPasswordForm, SendConfirmationForm, \ PasswordlessLoginForm -from flask_security.passwordless import send_login_instructions, login_by_token -from flask_security.recoverable import reset_by_token, \ - send_reset_password_instructions +from flask_security.passwordless import send_login_instructions, \ + login_token_status +from flask_security.recoverable import reset_password_token_status, \ + send_reset_password_instructions, update_password from flask_security.registerable import register_user from flask_security.utils import get_url, get_post_login_redirect, do_flash, \ get_message, config_value, login_user, logout_user, \ @@ -64,10 +63,10 @@ def _ctx(endpoint): def login(): """View function for login view""" - form_data = request.form - if request.json: form_data = MultiDict(request.json) + else: + form_data = request.form form = LoginForm(form_data, csrf_enabled=not app.testing) @@ -131,7 +130,7 @@ def send_login(): form = PasswordlessLoginForm(csrf_enabled=not app.testing) if form.validate_on_submit(): - send_login_instructions(**form.to_dict()) + send_login_instructions(form.user) do_flash(*get_message('LOGIN_EMAIL_SENT', email=form.user.email)) return render_template('security/send_login.html', @@ -142,17 +141,22 @@ def send_login(): @anonymous_user_required def token_login(token): """View function that handles passwordless login via a token""" + expired, invalid, user = login_token_status(token) - try: - user, next = login_by_token(token) - except PasswordlessLoginError, e: - if e.user: - send_login_instructions(e.user, e.next) - do_flash(str(e), 'error') - return redirect(request.referrer or url_for('login')) + if invalid: + do_flash(*get_message('INVALID_LOGIN_TOKEN')) + if expired: + send_login_instructions(user) + do_flash(*get_message('LOGIN_EXPIRED', email=user.email, + within=_security.login_within)) + if invalid or expired: + return redirect(url_for('login')) + login_user(user, True) + after_this_request(_commit) do_flash(*get_message('PASSWORDLESS_LOGIN_SUCCESSFUL')) - return redirect(next) + + return redirect(get_post_login_redirect()) @anonymous_user_required @@ -173,22 +177,26 @@ def send_confirmation(): @anonymous_user_required def confirm_email(token): """View function which handles a email confirmation request.""" - after_this_request(_commit) - try: - user = confirm_by_token(token) - except ConfirmationError, e: - if e.user: - send_confirmation_instructions(e.user) - do_flash(str(e), 'error') - confirm_error_url = get_url(_security.confirm_error_view) - return redirect(confirm_error_url or url_for('send_confirmation')) + expired, invalid, user = confirm_email_token_status(token) - do_flash(*get_message('EMAIL_CONFIRMED')) + if invalid: + do_flash(*get_message('INVALID_CONFIRMATION_TOKEN')) + if expired: + send_confirmation_instructions(user) + do_flash(*get_message('CONFIRMATION_EXPIRED', email=user.email, + within=_security.confirm_email_within)) + if invalid or expired: + return redirect(get_url(_security.confirm_error_view) or + url_for('send_confirmation')) + + confirm_user(user) login_user(user, True) - post_confirm_url = get_url(_security.post_confirm_view) - post_login_url = get_url(_security.post_login_view) - return redirect(post_confirm_url or post_login_url) + after_this_request(_commit) + do_flash(*get_message('EMAIL_CONFIRMED')) + + return redirect(get_url(_security.post_confirm_view) or + get_url(_security.post_login_view)) @anonymous_user_required @@ -210,25 +218,24 @@ def forgot_password(): def reset_password(token): """View function that handles a reset password request.""" - next = None - form = ResetPasswordForm(reset_token=token, csrf_enabled=not app.testing) + expired, invalid, user = reset_password_token_status(token) - if form.validate_on_submit(): - try: - user = reset_by_token(token=token, **form.to_dict()) - msg = get_message('PASSWORD_RESET') - next = (get_url(_security.post_reset_view) or - get_url(_security.post_login_view)) - except ResetPasswordError, e: - msg = (str(e), 'error') - if e.user: - send_reset_password_instructions(e.user) + if invalid: + do_flash(*get_message('INVALID_RESET_PASSWORD_TOKEN')) + if expired: + do_flash(*get_message('PASSWORD_RESET_EXPIRED', email=user.email, + within=_security.reset_password_within)) + if invalid or expired: + return redirect(url_for('forgot_password')) - do_flash(*msg) + form = ResetPasswordForm(csrf_enabled=not app.testing) - if next: - login_user(user) - return redirect(next) + if form.validate_on_submit(): + update_password(user, form.password.data) + do_flash(*get_message('PASSWORD_RESET')) + login_user(user, True) + return redirect(get_url(_security.post_reset_view) or + get_url(_security.post_login_view)) return render_template('security/reset_password.html', reset_password_form=form, diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 4cd6c076..73c5546c 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -321,6 +321,10 @@ def test_invalid_token_when_confirming_email(self): r = self.client.get('/confirm/bogus', follow_redirects=True) self.assertIn('Invalid confirmation token', r.data) + def test_send_confirmation_with_invalid_email(self): + r = self._post('/confirm', data=dict(email='bogus@bogus.com')) + self.assertIn('Specified user does not exist', r.data) + def test_resend_confirmation(self): e = 'dude@lp.com' self.register(e) @@ -378,6 +382,15 @@ class RecoverableTests(SecurityTest): 'SECURITY_POST_FORGOT_VIEW': '/' } + def test_reset_view(self): + with capture_reset_password_requests() as requests: + r = self.client.post('/reset', + data=dict(email='joe@lp.com'), + follow_redirects=True) + t = requests[0]['token'] + r = self._get('/reset/' + t) + self.assertIn('

          Reset password

          ', r.data) + def test_forgot_post_sends_email(self): with capture_reset_password_requests(): with self.app.extensions['mail'].record_messages() as outbox: @@ -512,6 +525,10 @@ def test_token_login_forwards_to_post_login_view_when_already_authenticated(self r = self.client.get('/login/' + token, follow_redirects=True) self.assertNotIn(self.get_message('PASSWORDLESS_LOGIN_SUCCESSFUL'), r.data) + def test_send_login_with_invalid_email(self): + r = self._post('/login', data=dict(email='bogus@bogus.com')) + self.assertIn('Specified user does not exist', r.data) + class ExpiredLoginTokenTests(SecurityTest): diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py index 8ca14d5b..93e686f4 100644 --- a/tests/test_app/__init__.py +++ b/tests/test_app/__init__.py @@ -5,7 +5,6 @@ from flask.ext.security import login_required, roles_required, roles_accepted from flask.ext.security.decorators import http_auth_required, \ auth_token_required -from flask.ext.security.exceptions import RoleNotFoundError from flask.ext.security.utils import encrypt_password from werkzeug.local import LocalProxy @@ -105,10 +104,7 @@ def activate_user(): @app.route('/coverage/invalid_role') def invalid_role(): - try: - ds.find_role('bogus') - except RoleNotFoundError: - return 'success' + return 'success' if ds.find_role('bogus') is None else 'failure' return app From 6322b4cbe1e1872f554ca1117abffbba6af49bcd Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 23 Aug 2012 20:37:27 -0400 Subject: [PATCH 204/234] Clean up --- flask_security/core.py | 77 ++++++++++++------------------------ flask_security/decorators.py | 8 ---- flask_security/utils.py | 3 -- flask_security/views.py | 36 ++++++++--------- 4 files changed, 43 insertions(+), 81 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index cfa9d0b0..7fffed36 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -142,25 +142,34 @@ def _get_pwd_context(app): return CryptContext(schemes=[pw_hash], default=pw_hash) -def _get_serializer(app, salt): - secret_key = app.config.get('SECRET_KEY', 'secret-key') +def _get_serializer(app, name): + secret_key = app.config.get('SECRET_KEY') + salt = app.config.get('SECURITY_%s_SALT' % name.upper()) return URLSafeTimedSerializer(secret_key=secret_key, salt=salt) -def _get_remember_token_serializer(app): - return _get_serializer(app, app.config['SECURITY_REMEMBER_SALT']) +def _get_state(app, datastore, **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), + principal=_get_principal(app), + pwd_context=_get_pwd_context(app), + context_processors={}, + remember_token_serializer=_get_serializer(app, 'remember'), + login_serializer=_get_serializer(app, 'login'), + reset_serializer=_get_serializer(app, 'reset'), + confirm_serializer=_get_serializer(app, 'confirm') + )) -def _get_reset_serializer(app): - return _get_serializer(app, app.config['SECURITY_RESET_SALT']) + return _SecurityState(**kwargs) -def _get_confirm_serializer(app): - return _get_serializer(app, app.config['SECURITY_CONFIRM_SALT']) - - -def _get_login_serializer(app): - return _get_serializer(app, app.config['SECURITY_LOGIN_SALT']) +def _context_processor(): + return dict(url_for_security=url_for_security, security=_security) class RoleMixin(object): @@ -272,7 +281,7 @@ def __init__(self, app=None, datastore=None, **kwargs): if app is not None and datastore is not None: self._state = self.init_app(app, datastore, **kwargs) - def init_app(self, app, datastore=None, register_blueprint=True, **kwargs): + def init_app(self, app, datastore=None): """Initializes the Flask-Security extension for the specified application and datastore implentation. @@ -289,48 +298,12 @@ def init_app(self, app, datastore=None, register_blueprint=True, **kwargs): identity_loaded.connect_via(app)(_on_identity_loaded) - if register_blueprint: - name = cv('BLUEPRINT_NAME', app=app) - url_prefix = cv('URL_PREFIX', app=app) - bp = create_blueprint(app, name, __name__, - url_prefix=url_prefix, - template_folder='templates') - app.register_blueprint(bp) - - state = self._get_state(app, datastore, **kwargs) - + state = _get_state(app, datastore) + app.register_blueprint(create_blueprint(state, __name__)) + app.context_processor(_context_processor) app.extensions['security'] = state - app.context_processor(lambda: dict(url_for_security=url_for_security, - security=state)) - return state - def _get_state(self, app, datastore, **kwargs): - assert app is not None - assert datastore is not None - - for key, value in get_config(app).items(): - kwargs[key.lower()] = value - - for key, value in [ - ('app', app), - ('datastore', datastore), - ('login_manager', _get_login_manager(app)), - ('principal', _get_principal(app)), - ('pwd_context', _get_pwd_context(app)), - ('remember_token_serializer', _get_remember_token_serializer(app)), - ('context_processors', {})]: - kwargs[key] = value - - kwargs['login_serializer'] = ( - _get_login_serializer(app) if kwargs['passwordless'] else None) - kwargs['reset_serializer'] = ( - _get_reset_serializer(app) if kwargs['recoverable'] else None) - kwargs['confirm_serializer'] = ( - _get_confirm_serializer(app) if kwargs['confirmable'] else None) - - return _SecurityState(**kwargs) - def __getattr__(self, name): return getattr(self._state, name, None) diff --git a/flask_security/decorators.py b/flask_security/decorators.py index e61b41c3..57095cbe 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -22,8 +22,6 @@ # Convenient references _security = LocalProxy(lambda: current_app.extensions['security']) -_logger = LocalProxy(lambda: current_app.logger) - _default_unauthorized_html = """

          Unauthorized

          @@ -129,8 +127,6 @@ def decorated_view(*args, **kwargs): perms = [Permission(RoleNeed(role)) for role in roles] for perm in perms: if not perm.can(): - _logger.debug('Identity does not provide the ' - 'roles: %s' % [r for r in roles]) return _get_unauthorized_view() return fn(*args, **kwargs) return decorated_view @@ -157,10 +153,6 @@ def decorated_view(*args, **kwargs): perm = Permission(*[RoleNeed(role) for role in roles]) if perm.can(): return fn(*args, **kwargs) - r1 = [r for r in roles] - r2 = [r.name for r in current_user.roles] - _logger.debug('Current user does not provide a required role. ' - 'Accepted: %s Provided: %s' % (r1, r2)) return _get_unauthorized_view() return decorated_view return wrapper diff --git a/flask_security/utils.py b/flask_security/utils.py index 428c53ad..fc67e1d3 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -37,9 +37,6 @@ _pwd_context = LocalProxy(lambda: _security.pwd_context) -_logger = LocalProxy(lambda: current_app.logger) - - def anonymous_user_required(f): @wraps(f) def wrapper(*args, **kwargs): diff --git a/flask_security/views.py b/flask_security/views.py index e03898c7..7d94735a 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -243,45 +243,45 @@ def reset_password(token): **_ctx('reset_password')) -def create_blueprint(app, name, import_name, **kwargs): +def create_blueprint(state, import_name): """Creates the security extension blueprint""" - bp = Blueprint(name, import_name, **kwargs) + bp = Blueprint(state.blueprint_name, import_name, + url_prefix=state.url_prefix, + template_folder='templates') - if config_value('PASSWORDLESS', app=app): - bp.route(config_value('LOGIN_URL', app=app), + bp.route(state.logout_url, endpoint='logout')(logout) + + if state.passwordless: + bp.route(state.login_url, methods=['GET', 'POST'], endpoint='login')(send_login) - - bp.route(config_value('LOGIN_URL', app=app) + '/', + bp.route(state.login_url + '/', methods=['GET'], endpoint='token_login')(token_login) else: - bp.route(config_value('LOGIN_URL', app=app), + bp.route(state.login_url, methods=['GET', 'POST'], endpoint='login')(login) - bp.route(config_value('LOGOUT_URL', app=app), - endpoint='logout')(logout) - - if config_value('REGISTERABLE', app=app): - bp.route(config_value('REGISTER_URL', app=app), + if state.registerable: + bp.route(state.register_url, methods=['GET', 'POST'], endpoint='register')(register) - if config_value('RECOVERABLE', app=app): - bp.route(config_value('RESET_URL', app=app), + if state.recoverable: + bp.route(state.reset_url, methods=['GET', 'POST'], endpoint='forgot_password')(forgot_password) - bp.route(config_value('RESET_URL', app=app) + '/', + bp.route(state.reset_url + '/', methods=['GET', 'POST'], endpoint='reset_password')(reset_password) - if config_value('CONFIRMABLE', app=app): - bp.route(config_value('CONFIRM_URL', app=app), + if state.confirmable: + bp.route(state.confirm_url, methods=['GET', 'POST'], endpoint='send_confirmation')(send_confirmation) - bp.route(config_value('CONFIRM_URL', app=app) + '/', + bp.route(state.confirm_url + '/', methods=['GET', 'POST'], endpoint='confirm_email')(confirm_email) From bdf61b146cc5643d589c162acd72d394bc07a01c Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 23 Aug 2012 20:39:43 -0400 Subject: [PATCH 205/234] Remove unnecessary exceptions file and more clean up --- flask_security/exceptions.py | 52 ------------------------------------ flask_security/views.py | 1 - 2 files changed, 53 deletions(-) delete mode 100644 flask_security/exceptions.py diff --git a/flask_security/exceptions.py b/flask_security/exceptions.py deleted file mode 100644 index 47be66b9..00000000 --- a/flask_security/exceptions.py +++ /dev/null @@ -1,52 +0,0 @@ -# -*- coding: utf-8 -*- -""" - flask.ext.security.exceptions - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - Flask-Security exceptions module - - :copyright: (c) 2012 by Matt Wright. - :license: MIT, see LICENSE for more details. -""" - - -class SecurityError(Exception): - def __init__(self, message=None, user=None): - super(SecurityError, self).__init__(message) - self.user = user - - -class BadCredentialsError(SecurityError): - """Raised when an authentication attempt fails due to an error with the - provided credentials. - """ - - -class UserNotFoundError(SecurityError): - """Raised by a user datastore when there is an attempt to find a user by - their identifier, often username or email, and the user is not found. - """ - - -class RoleNotFoundError(SecurityError): - """Raised by a user datastore when there is an attempt to find a role and - the role cannot be found. - """ - - -class ConfirmationError(SecurityError): - """Raised when an confirmation error occurs - """ - - -class ResetPasswordError(SecurityError): - """Raised when a password reset error occurs - """ - - -class PasswordlessLoginError(SecurityError): - """Raised when a passwordless login error occurs - """ - def __init__(self, message=None, user=None, next=None): - super(PasswordlessLoginError, self).__init__(message, user) - self.next = next diff --git a/flask_security/views.py b/flask_security/views.py index 7d94735a..f6834d3b 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -257,7 +257,6 @@ def create_blueprint(state, import_name): methods=['GET', 'POST'], endpoint='login')(send_login) bp.route(state.login_url + '/', - methods=['GET'], endpoint='token_login')(token_login) else: bp.route(state.login_url, From b052e09cd6c7b0ddcfe42eab61f8ea58224d83da Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 23 Aug 2012 20:47:48 -0400 Subject: [PATCH 206/234] Polish --- flask_security/core.py | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 7fffed36..5f042a5f 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -158,11 +158,12 @@ def _get_state(app, datastore, **kwargs): login_manager=_get_login_manager(app), principal=_get_principal(app), pwd_context=_get_pwd_context(app), - context_processors={}, remember_token_serializer=_get_serializer(app, 'remember'), login_serializer=_get_serializer(app, 'login'), reset_serializer=_get_serializer(app, 'reset'), - confirm_serializer=_get_serializer(app, 'confirm') + confirm_serializer=_get_serializer(app, 'confirm'), + _context_processors={}, + _send_mail_task=None )) return _SecurityState(**kwargs) @@ -217,31 +218,20 @@ class _SecurityState(object): def __init__(self, **kwargs): for key, value in kwargs.items(): setattr(self, key.lower(), value) - self._send_mail_task = None def _add_ctx_processor(self, endpoint, fn): - c = self.context_processors - - if endpoint not in c: - c[endpoint] = [] - - if fn not in c[endpoint]: - c[endpoint].append(fn) + group = self._context_processors.setdefault(endpoint, []) + fn not in group and group.append(fn) def _run_ctx_processor(self, endpoint): rv, fns = {}, [] - - for g in ['all', endpoint]: - if g in self.context_processors: - fns += self.context_processors[g] - - for fn in fns: - rv.update(fn()) - + for g in [None, endpoint]: + for fn in self._context_processors.setdefault(g, []): + rv.update(fn()) return rv def context_processor(self, fn): - self._add_ctx_processor('all', fn) + self._add_ctx_processor(None, fn) def forgot_password_context_processor(self, fn): self._add_ctx_processor('forgot_password', fn) From f1c52d01aa24bf144c4303d0fc71bed3beaecc94 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 23 Aug 2012 20:56:13 -0400 Subject: [PATCH 207/234] Even more polish --- flask_security/core.py | 2 +- flask_security/forms.py | 15 +++++++++----- flask_security/script.py | 39 ----------------------------------- flask_security/views.py | 44 ++++++++++++++++++---------------------- 4 files changed, 31 insertions(+), 69 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 5f042a5f..3766de4b 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -9,12 +9,12 @@ :license: MIT, see LICENSE for more details. """ -from itsdangerous import URLSafeTimedSerializer from flask import current_app from flask.ext.login import AnonymousUser as AnonymousUserBase, \ UserMixin as BaseUserMixin, LoginManager, current_user from flask.ext.principal import Principal, RoleNeed, UserNeed, Identity, \ identity_loaded +from itsdangerous import URLSafeTimedSerializer from passlib.context import CryptContext from werkzeug.datastructures import ImmutableList from werkzeug.local import LocalProxy diff --git a/flask_security/forms.py b/flask_security/forms.py index 613b99e4..38957fcf 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -9,17 +9,17 @@ :license: MIT, see LICENSE for more details. """ -from flask import request, current_app as app -from flask.ext.wtf import Form, TextField, PasswordField, SubmitField, \ - HiddenField, Required, BooleanField, EqualTo, Email, ValidationError, \ - Length +from flask import request, current_app +from flask.ext.wtf import Form as BaseForm, TextField, PasswordField, \ + SubmitField, HiddenField, Required, BooleanField, EqualTo, Email, \ + ValidationError, Length from werkzeug.local import LocalProxy from .confirmable import requires_confirmation from .utils import verify_password, get_message # Convenient reference -_datastore = LocalProxy(lambda: app.extensions['security'].datastore) +_datastore = LocalProxy(lambda: current_app.extensions['security'].datastore) email_required = Required(message='Email not provided') @@ -40,6 +40,11 @@ def valid_user_email(form, field): raise ValidationError('Specified user does not exist') +class Form(BaseForm): + def __init__(self, *args, **kwargs): + super(Form, self).__init__(csrf_enabled=not current_app.testing, + *args, **kwargs) + class EmailFormMixin(): email = TextField("Email Address", validators=[email_required, diff --git a/flask_security/script.py b/flask_security/script.py index 0bf3f167..f81159d9 100644 --- a/flask_security/script.py +++ b/flask_security/script.py @@ -105,42 +105,3 @@ class ActivateUserCommand(_ToggleActiveCommand): def run(self, user_identifier): _datastore.activate_user(user_identifier) print "User '%s' has been activated" % user_identifier - - -class GenerateBlueprintCommand(Command): - """Generate a Flask-Security blueprint object""" - - option_list = ( - Option('--output', '-o', dest='output', default=None), - ) - - def run(self, output): - output = os.path.join(os.getcwd(), output) if output else 'security.py' - - if os.path.exists(output): - msg = 'File %s exists. Do you want to overwrite it?' % output - if not prompt_bool(msg): - return - - with open(output, 'w') as o: - source = inspect.getfile(views).replace('.pyc', '.py') - - with open(source, 'r') as s: - to_remove = '"""' + views.__doc__ + '"""' - to_replace = """ -\""" - Flask-Security - ~~~~~~~~~~~~~~ - - This module was generated by Flask-Security to give developers greater - control over the various security mechanisms. For more information about - using this feature see: - - TODO: Documentation URL -\""" -""" - contents = s.read().replace(to_remove, to_replace) - o.write(contents) - - print 'File generated successfully.' - print output diff --git a/flask_security/views.py b/flask_security/views.py index f6834d3b..376ff099 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -9,29 +9,29 @@ :license: MIT, see LICENSE for more details. """ -from flask import current_app as app, redirect, request, \ - render_template, jsonify, after_this_request, Blueprint +from flask import current_app, redirect, request, render_template, jsonify, \ + after_this_request, Blueprint from werkzeug.datastructures import MultiDict from werkzeug.local import LocalProxy -from flask_security.confirmable import send_confirmation_instructions, \ +from .confirmable import send_confirmation_instructions, \ confirm_user, confirm_email_token_status -from flask_security.decorators import login_required -from flask_security.forms import LoginForm, ConfirmRegisterForm, RegisterForm, \ +from .decorators import login_required +from .forms import LoginForm, ConfirmRegisterForm, RegisterForm, \ ForgotPasswordForm, ResetPasswordForm, SendConfirmationForm, \ PasswordlessLoginForm -from flask_security.passwordless import send_login_instructions, \ +from .passwordless import send_login_instructions, \ login_token_status -from flask_security.recoverable import reset_password_token_status, \ +from .recoverable import reset_password_token_status, \ send_reset_password_instructions, update_password -from flask_security.registerable import register_user -from flask_security.utils import get_url, get_post_login_redirect, do_flash, \ - get_message, config_value, login_user, logout_user, \ - anonymous_user_required, url_for_security as url_for +from .registerable import register_user +from .utils import get_url, get_post_login_redirect, do_flash, \ + get_message, login_user, logout_user, anonymous_user_required, \ + url_for_security as url_for # Convenient references -_security = LocalProxy(lambda: app.extensions['security']) +_security = LocalProxy(lambda: current_app.extensions['security']) _datastore = LocalProxy(lambda: _security.datastore) @@ -64,11 +64,9 @@ def login(): """View function for login view""" if request.json: - form_data = MultiDict(request.json) + form = LoginForm(MultiDict(request.json)) else: - form_data = request.form - - form = LoginForm(form_data, csrf_enabled=not app.testing) + form = LoginForm() if form.validate_on_submit(): login_user(form.user, remember=form.remember.data) @@ -100,11 +98,9 @@ def register(): """View function which handles a registration request.""" if _security.confirmable: - form = ConfirmRegisterForm + form = ConfirmRegisterForm() else: - form = RegisterForm - - form = form(csrf_enabled=not app.testing) + form = RegisterForm() if form.validate_on_submit(): user = register_user(**form.to_dict()) @@ -127,7 +123,7 @@ def register(): def send_login(): """View function that sends login instructions for passwordless login""" - form = PasswordlessLoginForm(csrf_enabled=not app.testing) + form = PasswordlessLoginForm() if form.validate_on_submit(): send_login_instructions(form.user) @@ -163,7 +159,7 @@ def token_login(token): def send_confirmation(): """View function which sends confirmation instructions.""" - form = SendConfirmationForm(csrf_enabled=not app.testing) + form = SendConfirmationForm() if form.validate_on_submit(): send_confirmation_instructions(form.user) @@ -203,7 +199,7 @@ def confirm_email(token): def forgot_password(): """View function that handles a forgotten password request.""" - form = ForgotPasswordForm(csrf_enabled=not app.testing) + form = ForgotPasswordForm() if form.validate_on_submit(): send_reset_password_instructions(form.user) @@ -228,7 +224,7 @@ def reset_password(token): if invalid or expired: return redirect(url_for('forgot_password')) - form = ResetPasswordForm(csrf_enabled=not app.testing) + form = ResetPasswordForm() if form.validate_on_submit(): update_password(user, form.password.data) From bac04a0f3c14e3334f3900a50d464334df26328f Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 23 Aug 2012 23:48:55 -0400 Subject: [PATCH 208/234] remove more unnecessary code --- flask_security/core.py | 3 +-- flask_security/utils.py | 34 ++++++++++------------------------ tests/functional_tests.py | 2 +- 3 files changed, 12 insertions(+), 27 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 3766de4b..ec60e924 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -32,8 +32,7 @@ 'URL_PREFIX': None, 'FLASH_MESSAGES': True, 'PASSWORD_HASH': 'plaintext', - 'PASSWORD_HMAC': False, - 'PASSWORD_HMAC_SALT': None, + 'PASSWORD_SALT': None, 'LOGIN_URL': '/login', 'LOGOUT_URL': '/logout', 'REGISTER_URL': '/register', diff --git a/flask_security/utils.py b/flask_security/utils.py index fc67e1d3..2385277f 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -12,6 +12,7 @@ import base64 import hashlib import hmac +import os from contextlib import contextmanager from datetime import datetime, timedelta from functools import wraps @@ -76,33 +77,18 @@ def logout_user(): _logout_user() -def get_hmac(msg, salt=None, digestmod=None): - digestmod = digestmod or hashlib.sha512 - return base64.b64encode(hmac.new(salt, msg, digestmod).digest()) +def get_hmac(password): + if _security.password_hash == 'plaintext': + return password + h = hmac.new(_security.password_salt, password, hashlib.sha512) + return base64.b64encode(h.digest()) +def verify_password(password, password_hash): + return _pwd_context.verify(get_hmac(password), password_hash) -def verify_password(password, password_hash, use_hmac=None): - if use_hmac is None: - use_hmac = _security.password_hmac - if use_hmac: - hmac_value = get_hmac(password, _security.password_hmac_salt) - else: - hmac_value = password - - return _pwd_context.verify(hmac_value, password_hash) - - -def encrypt_password(password, salt=None, use_hmac=None): - if use_hmac is None: - use_hmac = _security.password_hmac - - if use_hmac: - hmac_value = get_hmac(password, _security.password_hmac_salt) - else: - hmac_value = password - - return _pwd_context.encrypt(hmac_value) +def encrypt_password(password): + return _pwd_context.encrypt(get_hmac(password)) def md5(data): diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 73c5546c..51e5f008 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -203,7 +203,7 @@ class ConfiguredSecurityTests(SecurityTest): AUTH_CONFIG = { 'SECURITY_PASSWORD_HASH': 'bcrypt', - 'SECURITY_PASSWORD_HMAC_SALT': 'so-salty', + 'SECURITY_PASSWORD_SALT': 'so-salty', 'SECURITY_PASSWORD_HMAC': True, 'SECURITY_REGISTERABLE': True, 'SECURITY_LOGOUT_URL': '/custom_logout', From 23cc774f96b86b863733ebd3209d970b5187d247 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 24 Aug 2012 00:27:22 -0400 Subject: [PATCH 209/234] Add error for bad configuration --- flask_security/utils.py | 6 ++++++ tests/functional_tests.py | 11 ++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/flask_security/utils.py b/flask_security/utils.py index 2385277f..9a8fa6da 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -80,6 +80,12 @@ def logout_user(): def get_hmac(password): if _security.password_hash == 'plaintext': return password + + if _security.password_salt is None: + raise RuntimeError('The configuration value `SECURITY_PASSWORD_SALT` ' + 'must not be None when the value of `SECURITY_PASSWORD_HASH` is ' + 'set to "%s"' % _security.password_hash) + h = hmac.new(_security.password_salt, password, hashlib.sha512) return base64.b64encode(h.digest()) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 51e5f008..9415feaa 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -204,7 +204,6 @@ class ConfiguredSecurityTests(SecurityTest): AUTH_CONFIG = { 'SECURITY_PASSWORD_HASH': 'bcrypt', 'SECURITY_PASSWORD_SALT': 'so-salty', - 'SECURITY_PASSWORD_HMAC': True, 'SECURITY_REGISTERABLE': True, 'SECURITY_LOGOUT_URL': '/custom_logout', 'SECURITY_LOGIN_URL': '/custom_login', @@ -262,6 +261,16 @@ def test_default_http_auth_realm(self): self.assertEquals('Basic realm="Custom Realm"', r.headers['WWW-Authenticate']) +class BadConfiguredSecurityTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_PASSWORD_HASH': 'bcrypt', + } + + def test_bad_configuration_raises_runtimer_error(self): + self.assertRaises(RuntimeError, self.authenticate) + + class RegisterableTests(SecurityTest): AUTH_CONFIG = { 'SECURITY_REGISTERABLE': True From 7ddc132af5931777f37a323d2134a50f596dceda Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 24 Aug 2012 00:47:07 -0400 Subject: [PATCH 210/234] Update script commands a bit --- flask_security/script.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/flask_security/script.py b/flask_security/script.py index f81159d9..9e19e1f9 100644 --- a/flask_security/script.py +++ b/flask_security/script.py @@ -1,18 +1,25 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.script + ~~~~~~~~~~~~~~~~~~~~~~~~~ + Flask-Security script module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" try: import simplejson as json except ImportError: import json -import inspect -import os import re from flask import current_app -from flask.ext.script import Command, Option, prompt_bool +from flask.ext.script import Command, Option from werkzeug.local import LocalProxy -from flask_security import views, utils +from .utils import encrypt_password _datastore = LocalProxy(lambda: current_app.extensions['security'].datastore) @@ -22,6 +29,13 @@ def pprint(obj): print json.dumps(obj, sort_keys=True, indent=4) +def commit(fn): + def wrapper(*args, **kwargs): + fn(*args, **kwargs) + _datastore._commit() + return wrapper + + class CreateUserCommand(Command): """Create a user""" @@ -32,6 +46,7 @@ class CreateUserCommand(Command): Option('-r', '--roles', dest='roles', default=''), ) + @commit def run(self, **kwargs): # sanitize active input ai = re.sub(r'\s', '', str(kwargs['active'])) @@ -40,7 +55,7 @@ def run(self, **kwargs): # sanitize role input a bit ri = re.sub(r'\s', '', kwargs['roles']) kwargs['roles'] = [] if ri == '' else ri.split(',') - kwargs['password'] = utils.encrypt_password(kwargs['password']) + kwargs['password'] = encrypt_password(kwargs['password']) _datastore.create_user(**kwargs) @@ -57,6 +72,7 @@ class CreateRoleCommand(Command): Option('-d', '--desc', dest='description', default=None), ) + @commit def run(self, **kwargs): _datastore.create_role(**kwargs) print 'Role "%(name)s" created successfully.' % kwargs @@ -72,6 +88,7 @@ class _RoleCommand(Command): class AddRoleCommand(_RoleCommand): """Add a role to a user""" + @commit def run(self, user_identifier, role_name): _datastore.add_role_to_user(user_identifier, role_name) print "Role '%s' added to user '%s' successfully" % (role_name, user_identifier) @@ -80,6 +97,7 @@ def run(self, user_identifier, role_name): class RemoveRoleCommand(_RoleCommand): """Add a role to a user""" + @commit def run(self, user_identifier, role_name): _datastore.remove_role_from_user(user_identifier, role_name) print "Role '%s' removed from user '%s' successfully" % (role_name, user_identifier) @@ -94,6 +112,7 @@ class _ToggleActiveCommand(Command): class DeactivateUserCommand(_ToggleActiveCommand): """Deactive a user""" + @commit def run(self, user_identifier): _datastore.deactivate_user(user_identifier) print "User '%s' has been deactivated" % user_identifier @@ -102,6 +121,7 @@ def run(self, user_identifier): class ActivateUserCommand(_ToggleActiveCommand): """Deactive a user""" + @commit def run(self, user_identifier): _datastore.activate_user(user_identifier) print "User '%s' has been activated" % user_identifier From 4e41f4ec5e90cd6288f80a2eb957bf9dc89e635d Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 24 Aug 2012 00:47:41 -0400 Subject: [PATCH 211/234] Polish up tests --- tests/__init__.py | 2 ++ tests/test_app/mongoengine.py | 7 +++++++ tests/test_app/sqlalchemy.py | 8 ++++++++ 3 files changed, 17 insertions(+) diff --git a/tests/__init__.py b/tests/__init__.py index 3392fab8..9742239a 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + from unittest import TestCase from tests.test_app.sqlalchemy import create_app diff --git a/tests/test_app/mongoengine.py b/tests/test_app/mongoengine.py index e1efdece..eb61df46 100644 --- a/tests/test_app/mongoengine.py +++ b/tests/test_app/mongoengine.py @@ -1,3 +1,10 @@ +# -*- coding: utf-8 -*- + +import sys +import os + +sys.path.pop(0) +sys.path.insert(0, os.getcwd()) from flask.ext.mongoengine import MongoEngine from flask.ext.security import Security, UserMixin, RoleMixin, \ diff --git a/tests/test_app/sqlalchemy.py b/tests/test_app/sqlalchemy.py index 555af759..04896a0e 100644 --- a/tests/test_app/sqlalchemy.py +++ b/tests/test_app/sqlalchemy.py @@ -1,3 +1,11 @@ +# -*- coding: utf-8 -*- + +import sys +import os + +sys.path.pop(0) +sys.path.insert(0, os.getcwd()) + from flask.ext.sqlalchemy import SQLAlchemy from flask.ext.security import Security, UserMixin, RoleMixin, \ From da9f683c2231b79f8becf0bf18b9a32e5c3c005b Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 24 Aug 2012 00:48:14 -0400 Subject: [PATCH 212/234] Update docs a bit --- docs/configuration.rst | 13 ++++--------- docs/models.rst | 26 +++++++++++++------------- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 5e49c770..ab289044 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -24,15 +24,10 @@ Core production systems are ``bcrypt``, ``sha512_crypt``, or ``pbkdf2_sha512``. Defaults to ``plaintext``. -``SECURITY_PASSWORD_HMAC`` Specifies if Flask-Security should also - use HMAC when encrypting and decrypting - passwords. If set to ``True`` be sure - to specify a salt value via the - ``SECURITY_PASSWORD_HMAC_SALT`` - configuration option. Defaults to - ``False``. -``SECURITY_PASSWORD_HMAC_SALT`` Specifies the HMAC salt. Defaults to - ``None``. +``SECURITY_PASSWORD_SALT`` Specifies the HMAC salt. This is only + used if the password hash type is set + to something other than plain text. + Defaults to ``None``. ``SECURITY_EMAIL_SENDER`` Specifies the email address to send emails as. Defaults to ``no-reply@localhost``. diff --git a/docs/models.rst b/docs/models.rst index 4119c01a..6ea6b152 100644 --- a/docs/models.rst +++ b/docs/models.rst @@ -10,16 +10,16 @@ and `Role` model should include the following fields: **User** -* id -* email -* password -* active +* ``id`` +* ``email`` +* ``password`` +* ``active`` **Role** -* id -* name -* description +* ``id`` +* ``name`` +* ``description`` Additional Functionality @@ -35,7 +35,7 @@ If you enable account confirmation by setting your application's `SECURITY_CONFIRMABLE` configuration value to `True` your `User` model will require the following additional field: -* confirmed_at +* ``confirmed_at`` Trackable ^^^^^^^^^ @@ -44,8 +44,8 @@ If you enable user tracking by setting your application's `SECURITY_TRACKABLE` configuration value to `True` your `User` model will require the following additional fields: -* last_login_at -* current_login_at -* last_login_ip -* current_login_ip -* login_count \ No newline at end of file +* ``last_login_at`` +* ``current_login_at`` +* ``last_login_ip`` +* ``current_login_ip`` +* ``login_count`` \ No newline at end of file From f928db298d15ef29f0b2536b320032d73d5293b7 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 24 Aug 2012 11:38:25 -0400 Subject: [PATCH 213/234] Refactor datastore implementation --- flask_security/confirmable.py | 4 +- flask_security/core.py | 6 +- flask_security/datastore.py | 220 +++++++++++++++------------------ flask_security/recoverable.py | 2 +- flask_security/registerable.py | 2 +- flask_security/script.py | 2 +- flask_security/utils.py | 2 +- flask_security/views.py | 2 +- tests/functional_tests.py | 2 +- tests/test_app/__init__.py | 5 +- tests/unit_tests.py | 61 ++++++--- 11 files changed, 161 insertions(+), 147 deletions(-) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 32ac958f..d53c0a33 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -58,7 +58,7 @@ def generate_confirmation_token(user): def requires_confirmation(user): """Returns `True` if the user requires confirmation.""" - return user.confirmed_at == None and _security.confirmable + return _security.confirmable and user.confirmed_at == None def confirm_email_token_status(token): @@ -78,5 +78,5 @@ def confirm_user(user): :param user: The user to confirm """ user.confirmed_at = datetime.utcnow() - _datastore._save_model(user) + _datastore.put(user) user_confirmed.send(user, app=app._get_current_object()) diff --git a/flask_security/core.py b/flask_security/core.py index ec60e924..1197d82b 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -175,10 +175,12 @@ def _context_processor(): class RoleMixin(object): """Mixin for `Role` model definitions""" def __eq__(self, other): - return self.name == other or self.name == getattr(other, 'name', None) + return (self.name == other or \ + self.name == getattr(other, 'name', None)) def __ne__(self, other): - return self.name != other and self.name != getattr(other, 'name', None) + return (self.name != other and + self.name != getattr(other, 'name', None)) class UserMixin(BaseUserMixin): diff --git a/flask_security/datastore.py b/flask_security/datastore.py index 3d46ca24..975c0f0e 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -9,64 +9,51 @@ :license: MIT, see LICENSE for more details. """ -class UserDatastore(object): - """Abstracted user datastore. Always extend this class and implement the - :attr:`_save_model`, :attr:`_delete_model`, :attr:`_do_find_user`, and - :attr:`_do_find_role` methods. - - :param db: An instance of a configured databse manager from a Flask - extension such as Flask-SQLAlchemy or Flask-MongoEngine - :param user_model: A user model class definition - :param role_model: A role model class definition - """ - - def __init__(self, db, user_model, role_model): +class Datastore(object): + def __init__(self, db): self.db = db - self.user_model = user_model - self.role_model = role_model - def _commit(self, *args, **kwargs): + def commit(self): pass - def _save_model(self, model, **kwargs): - raise NotImplementedError( - "User datastore does not implement _save_model method") + def put(self, model): + raise NotImplementedError - def _delete_model(self, model): - raise NotImplementedError( - "User datastore does not implement _delete_model method") + def delete(self, model): + raise NotImplementedError - def _do_find_user(self, **kwargs): - raise NotImplementedError( - "User datastore does not implement _do_find_user method") - def _do_find_role(self, **kwargs): - raise NotImplementedError( - "User datastore does not implement _do_find_role method") +class SQLAlchemyDatastore(Datastore): + def commit(self): + self.db.session.commit() - def _do_add_role(self, user, role): - user, role = self._prepare_role_modify_args(user, role) - if role not in user.roles: - user.roles.append(role) - return user + def put(self, model): + self.db.session.add(model) + return model - def _do_remove_role(self, user, role): - user, role = self._prepare_role_modify_args(user, role) - if role in user.roles: - user.roles.remove(role) - return user + def delete(self, model): + self.db.session.delete(model) - def _do_toggle_active(self, user, active): - user = self.find_user(email=user.email) - if active != user.active: - user.active = active - return user - def _do_deactive_user(self, user): - return self._do_toggle_active(user, False) +class MongoEngineDatastore(Datastore): + def put(self, model): + model.save() + return model - def _do_active_user(self, user): - return self._do_toggle_active(user, True) + def delete(self, model): + model.delete() + + +class UserDatastore(object): + """Abstracted user datastore. + + :param user_model: A user model class definition + :param role_model: A role model class definition + """ + + def __init__(self, user_model, role_model): + self.user_model = user_model + self.role_model = role_model def _prepare_role_modify_args(self, user, role): role = role.name if isinstance(role, self.role_model) else role @@ -75,124 +62,117 @@ def _prepare_role_modify_args(self, user, role): def _prepare_create_user_args(self, **kwargs): kwargs.setdefault('active', True) roles = kwargs.get('roles', []) - for i, role in enumerate(roles): rn = role.name if isinstance(role, self.role_model) else role # see if the role exists roles[i] = self.find_role(rn) - kwargs['roles'] = roles - return kwargs def find_user(self, **kwargs): - """Returns a user based on the specified identifier. + """Returns a user matching the provided paramters.""" + raise NotImplementedError - :param user: User identifier, usually email address - """ - return self._do_find_user(**kwargs) + def find_role(self, **kwargs): + """Returns a role matching the provided paramters.""" + raise NotImplementedError - def find_role(self, role): - """Returns a role based on its name. + def add_role_to_user(self, user, role): + """Adds a role tp a user - :param role: Role name + :param user: The user to manipulate + :param role: The role to add to the user """ - return self._do_find_role(role) + rv = False + user, role = self._prepare_role_modify_args(user, role) + if role not in user.roles: + rv = True + user.roles.append(role) + return rv - def create_role(self, **kwargs): - """Creates and returns a new role. + def remove_role_from_user(self, user, role): + """Removes a role from a user - :param name: Role name + :param user: The user to manipulate + :param role: The role to remove from the user """ - role = self.role_model(**kwargs) - return self._save_model(role) + rv = False + user, role = self._prepare_role_modify_args(user, role) + if role in user.roles: + rv = True + user.roles.remove(role) + return rv - def create_user(self, **kwargs): - """Creates and returns a new user. + def toggle_active(self, user): + """Toggles a user's active status. Always returns True.""" + user.active = not user.active + return True - :param email: Email address - :param password: Unencrypted password - :param active: The optional active state - """ - user = self.user_model(**self._prepare_create_user_args(**kwargs)) - return self._save_model(user) - - def delete_user(self, user): - """Delete the specified user + def deactivate_user(self, user): + """Deactivates a specified user. Returns `True` if a change was made. - :param user: The user to delete_user + :param user: The user to deactivate """ - self._delete_model(user) + if user.active: + user.active = False + return True + return False - def add_role_to_user(self, user, role): - """Adds a role to a user if the user does not have it already. Returns - the modified user. + def activate_user(self, user): + """Activates a specified user. Returns `True` if a change was made. - :param user: A User instance or a user identifier - :param role: A Role instance or a role name + :param user: The user to activate """ - return self._save_model(self._do_add_role(user, role)) + if not user.active: + user.active = True + return True + return False - def remove_role_from_user(self, user, role): - """Removes a role from a user if the user has the role. Returns the - modified user. + def create_role(self, **kwargs): + """Creates and returns a new role from the given parameters.""" - :param user: A User instance or a user identifier - :param role: A Role instance or a role name - """ - return self._save_model(self._do_remove_role(user, role)) + role = self.role_model(**kwargs) + return self.put(role) - def deactivate_user(self, user): - """Deactivates a user and returns the modified user. + def create_user(self, **kwargs): + """Creates and returns a new user from the given parameters.""" - :param user: A User instance or a user identifier - """ - return self._save_model(self._do_deactive_user(user)) + user = self.user_model(**self._prepare_create_user_args(**kwargs)) + return self.put(user) - def activate_user(self, user): - """Activates a user and returns the modified user. + def delete_user(self, user): + """Delete the specified user - :param user: A User instance or a user identifier + :param user: The user to delete """ - return self._save_model(self._do_active_user(user)) + self.delete(user) -class SQLAlchemyUserDatastore(UserDatastore): +class SQLAlchemyUserDatastore(SQLAlchemyDatastore, UserDatastore): """A SQLAlchemy datastore implementation for Flask-Security that assumes the use of the Flask-SQLAlchemy extension. """ + def __init__(self, db, user_model, role_model): + SQLAlchemyDatastore.__init__(self, db) + UserDatastore.__init__(self, user_model, role_model) - def _commit(self, *args, **kwargs): - self.db.session.commit() - - def _save_model(self, model): - self.db.session.add(model) - return model - - def _delete_model(self, model): - self.db.session.delete(model) - - def _do_find_user(self, **kwargs): + def find_user(self, **kwargs): return self.user_model.query.filter_by(**kwargs).first() - def _do_find_role(self, role): + def find_role(self, role): return self.role_model.query.filter_by(name=role).first() -class MongoEngineUserDatastore(UserDatastore): +class MongoEngineUserDatastore(MongoEngineDatastore, UserDatastore): """A MongoEngine datastore implementation for Flask-Security that assumes the use of the Flask-MongoEngine extension. """ + def __init__(self, db, user_model, role_model): + MongoEngineDatastore.__init__(self, db) + UserDatastore.__init__(self, user_model, role_model) - def _save_model(self, model): - model.save() - return model - - def _delete_model(self, model): - model.delete() - - def _do_find_user(self, **kwargs): + def find_user(self, **kwargs): return self.user_model.objects(**kwargs).first() - def _do_find_role(self, role): + def find_role(self, role): return self.role_model.objects(name=role).first() diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index b9030f79..5138680a 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -75,6 +75,6 @@ def update_password(user, password): :param password: The unencrypted new password """ user.password = encrypt_password(password) - _datastore._save_model(user) + _datastore.put(user) send_password_reset_notice(user) password_reset.send(user, app=app._get_current_object()) diff --git a/flask_security/registerable.py b/flask_security/registerable.py index fa842d2d..2e29af19 100644 --- a/flask_security/registerable.py +++ b/flask_security/registerable.py @@ -26,7 +26,7 @@ def register_user(**kwargs): confirmation_link, token = None, None kwargs['password'] = encrypt_password(kwargs['password']) user = _datastore.create_user(**kwargs) - _datastore._commit() + _datastore.commit() if _security.confirmable: confirmation_link, token = generate_confirmation_link(user) diff --git a/flask_security/script.py b/flask_security/script.py index 9e19e1f9..7c798f28 100644 --- a/flask_security/script.py +++ b/flask_security/script.py @@ -32,7 +32,7 @@ def pprint(obj): def commit(fn): def wrapper(*args, **kwargs): fn(*args, **kwargs) - _datastore._commit() + _datastore.commit() return wrapper diff --git a/flask_security/utils.py b/flask_security/utils.py index 9a8fa6da..17511329 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -64,7 +64,7 @@ def login_user(user, remember=True): user.login_count = user.login_count + 1 if user.login_count else 1 - _datastore._save_model(user) + _datastore.put(user) identity_changed.send(current_app._get_current_object(), identity=Identity(user.id)) diff --git a/flask_security/views.py b/flask_security/views.py index 376ff099..dca3f61a 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -51,7 +51,7 @@ def _render_json(form): def _commit(response=None): - _datastore._commit() + _datastore.commit() return response diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 9415feaa..d0e99d77 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -182,7 +182,7 @@ def test_user_deleted_during_session_reverts_to_anonymous_user(self): with self.app.test_request_context('/'): user = self.app.security.datastore.find_user(email='matt@lp.com') self.app.security.datastore.delete_user(user) - self.app.security.datastore._commit() + self.app.security.datastore.commit() r = self._get('/') self.assertNotIn('Hello matt@lp.com', r.data) diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py index 93e686f4..4b7b5578 100644 --- a/tests/test_app/__init__.py +++ b/tests/test_app/__init__.py @@ -111,8 +111,7 @@ def invalid_role(): def create_roles(): for role in ('admin', 'editor', 'author'): ds.create_role(name=role) - ds._commit() - + ds.commit() def create_users(): for u in (('matt@lp.com', 'password', ['admin'], True), @@ -122,7 +121,7 @@ def create_users(): ('tiya@lp.com', 'password', [], False)): ds.create_user(email=u[0], password=encrypt_password(u[1]), roles=u[2], active=u[3]) - ds._commit() + ds.commit() def populate_data(): create_roles() diff --git a/tests/unit_tests.py b/tests/unit_tests.py index 44860a47..d08db383 100644 --- a/tests/unit_tests.py +++ b/tests/unit_tests.py @@ -3,18 +3,16 @@ import unittest from flask_security import RoleMixin, UserMixin, AnonymousUser -from flask_security.datastore import UserDatastore +from flask_security.datastore import Datastore, UserDatastore class Role(RoleMixin): - def __init__(self, name, description=None): + def __init__(self, name): self.name = name - self.description = description class User(UserMixin): - def __init__(self, username, email, roles): - self.username = username + def __init__(self, email, roles): self.email = email self.roles = roles @@ -22,7 +20,7 @@ def __init__(self, username, email, roles): admin2 = Role('admin') editor = Role('editor') -user = User('matt', 'matt@lp.com', [admin, editor]) +user = User('matt@lp.com', [admin, editor]) class SecurityEntityTests(unittest.TestCase): @@ -45,11 +43,46 @@ def test_anonymous_user_has_no_roles(self): self.assertFalse(au.has_role('admin')) -class UserDatastoreTests(unittest.TestCase): - - def test_unimplemented(self): - ds = UserDatastore(None, None, None) - self.assertRaises(NotImplementedError, ds._save_model, None) - self.assertRaises(NotImplementedError, ds._delete_model, None) - self.assertRaises(NotImplementedError, ds._do_find_user) - self.assertRaises(NotImplementedError, ds._do_find_role) +class DatastoreTests(unittest.TestCase): + + def setUp(self): + super(DatastoreTests, self).setUp() + self.ds = UserDatastore(None, None) + + def test_unimplemented_datastore_methods(self): + ds = Datastore(None) + self.assertRaises(NotImplementedError, ds.put, None) + self.assertRaises(NotImplementedError, ds.delete, None) + + def test_unimplemented_user_datastore_methods(self): + self.assertRaises(NotImplementedError, self.ds.find_user) + self.assertRaises(NotImplementedError, self.ds.find_role) + + def test_toggle_active(self): + user.active = True + rv = self.ds.toggle_active(user) + self.assertTrue(rv) + self.assertFalse(user.active) + rv = self.ds.toggle_active(user) + self.assertTrue(rv) + self.assertTrue(user.active) + + def test_deactivate_user(self): + user.active = True + rv = self.ds.deactivate_user(user) + self.assertTrue(rv) + self.assertFalse(user.active) + + def test_activate_user(self): + ds = UserDatastore(None, None) + user.active = False + ds.activate_user(user) + self.assertTrue(user.active) + + def test_deactivate_returns_false_if_already_false(self): + user.active = False + self.assertFalse(self.ds.deactivate_user(user)) + + def test_activate_returns_false_if_already_true(self): + user.active = True + self.assertFalse(self.ds.activate_user(user)) From 2042b8aa4cfe03b52f0082313ea4be877d660770 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 28 Aug 2012 16:25:20 -0400 Subject: [PATCH 214/234] Fixes #34 --- flask_security/views.py | 1 + tests/functional_tests.py | 1 + 2 files changed, 2 insertions(+) diff --git a/flask_security/views.py b/flask_security/views.py index dca3f61a..260d5ae1 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -227,6 +227,7 @@ def reset_password(token): form = ResetPasswordForm() if form.validate_on_submit(): + after_this_request(_commit) update_password(user, form.password.data) do_flash(*get_message('PASSWORD_RESET')) login_user(user, True) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index d0e99d77..ed0af9f7 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -424,6 +424,7 @@ def test_reset_password_with_valid_token(self): 'password_confirm': 'newpassword' }, follow_redirects=True) + r = self.logout() r = self.authenticate('joe@lp.com', 'newpassword') self.assertIn('Hello joe@lp.com', r.data) From d8e6ae41f1c6d07646e1321bd48a132dce4e1cf9 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 28 Aug 2012 16:55:43 -0400 Subject: [PATCH 215/234] Dropping 2.5 support for now due to WTForms --- .travis.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index dcc15386..947d3cc1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,14 +1,12 @@ language: python python: - - "2.5" - "2.6" - "2.7" install: - pip install . --quiet --use-mirrors - "if [[ $TRAVIS_PYTHON_VERSION != '2.7' ]]; then pip install importlib simplejson --quiet --use-mirrors; fi" - - "if [[ $TRAVIS_PYTHON_VERSION == '2.5' ]]; then pip install mongoengine==0.6.5 --quiet --use-mirrors; fi" - pip install nose Flask-SQLAlchemy Flask-MongoEngine py-bcrypt MySQL-python --quiet --use-mirrors - pip install https://github.com/rduplain/flask-mail/tarball/master From fdcce53823d6533f84218b8a21d67e9b18126dc1 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 10 Sep 2012 17:54:31 -0400 Subject: [PATCH 216/234] Update build since Flask-Mail moved --- .travis.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 947d3cc1..6d1dd825 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,8 +7,7 @@ python: install: - pip install . --quiet --use-mirrors - "if [[ $TRAVIS_PYTHON_VERSION != '2.7' ]]; then pip install importlib simplejson --quiet --use-mirrors; fi" - - pip install nose Flask-SQLAlchemy Flask-MongoEngine py-bcrypt MySQL-python --quiet --use-mirrors - - pip install https://github.com/rduplain/flask-mail/tarball/master + - pip install nose Flask-SQLAlchemy Flask-MongoEngine Flask-Mail py-bcrypt MySQL-python --quiet --use-mirrors before_script: - mysql -e 'create database flask_security_test;' From 364646fc6bda416fd959710bee28f1323ccb63d8 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Mon, 10 Sep 2012 18:14:27 -0400 Subject: [PATCH 217/234] Fix register_user.html --- flask_security/templates/security/register_user.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flask_security/templates/security/register_user.html b/flask_security/templates/security/register_user.html index 5d477ab7..87cf9b1d 100644 --- a/flask_security/templates/security/register_user.html +++ b/flask_security/templates/security/register_user.html @@ -5,7 +5,9 @@

          Register

          {{ register_user_form.hidden_tag() }} {{ render_field_with_errors(register_user_form.email) }} {{ render_field_with_errors(register_user_form.password) }} - {{ render_field_with_errors(register_user_form.password_confirm) }} + {% if register_user_form.password_confirm %} + {{ render_field_with_errors(register_user_form.password_confirm) }} + {% endif %} {{ render_field(register_user_form.submit) }} {% include "security/_menu.html" %} \ No newline at end of file From c1141b57faf7321390e82bba7c9ae51e82d800c5 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 11 Sep 2012 17:51:20 -0400 Subject: [PATCH 218/234] Add ability to not register blueprint on app. Useful if combining apps such as an API layer and a frontend where the API is not concerned with rendering templates or handling traditional auth --- flask_security/core.py | 9 ++++++--- tests/__init__.py | 4 ++-- tests/functional_tests.py | 16 ++++++++++++++++ tests/test_app/__init__.py | 2 +- tests/test_app/sqlalchemy.py | 5 +++-- 5 files changed, 28 insertions(+), 8 deletions(-) diff --git a/flask_security/core.py b/flask_security/core.py index 1197d82b..8d449cd7 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -272,7 +272,7 @@ def __init__(self, app=None, datastore=None, **kwargs): if app is not None and datastore is not None: self._state = self.init_app(app, datastore, **kwargs) - def init_app(self, app, datastore=None): + def init_app(self, app, datastore=None, register_blueprint=True): """Initializes the Flask-Security extension for the specified application and datastore implentation. @@ -290,8 +290,11 @@ def init_app(self, app, datastore=None): identity_loaded.connect_via(app)(_on_identity_loaded) state = _get_state(app, datastore) - app.register_blueprint(create_blueprint(state, __name__)) - app.context_processor(_context_processor) + + if register_blueprint: + app.register_blueprint(create_blueprint(state, __name__)) + app.context_processor(_context_processor) + app.extensions['security'] = state return state diff --git a/tests/__init__.py b/tests/__init__.py index 9742239a..6d1f451f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -17,8 +17,8 @@ def setUp(self): self.app = app self.client = app.test_client() - def _create_app(self, auth_config): - return create_app(auth_config) + def _create_app(self, auth_config, register_blueprint=True): + return create_app(auth_config, register_blueprint) def _get(self, route, content_type=None, follow_redirects=None, headers=None): return self.client.get(route, follow_redirects=follow_redirects, diff --git a/tests/functional_tests.py b/tests/functional_tests.py index ed0af9f7..7376f872 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -622,3 +622,19 @@ def send_email(msg): self.client.post('/reset', data=dict(email='joe@lp.com')) self.assertTrue(self.mail_sent) + + +class NoBlueprintTests(SecurityTest): + + def _create_app(self, auth_config): + return super(NoBlueprintTests, self)._create_app(auth_config, False) + + def test_login_endpoint_is_404(self): + r = self._get('/login') + self.assertEqual(404, r.status_code) + + def test_http_auth_without_blueprint(self): + r = self._get('/http', headers={ + 'Authorization': 'Basic ' + base64.b64encode("joe@lp.com:password") + }) + self.assertIn('HTTP Authentication', r.data) diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py index 4b7b5578..6ed14994 100644 --- a/tests/test_app/__init__.py +++ b/tests/test_app/__init__.py @@ -38,7 +38,7 @@ def post_login(): @app.route('/http') @http_auth_required def http(): - return render_template('index.html', content='HTTP Authentication') + return 'HTTP Authentication' @app.route('/http_custom_realm') @http_auth_required('My Realm') diff --git a/tests/test_app/sqlalchemy.py b/tests/test_app/sqlalchemy.py index 04896a0e..477bab3a 100644 --- a/tests/test_app/sqlalchemy.py +++ b/tests/test_app/sqlalchemy.py @@ -14,7 +14,7 @@ from tests.test_app import create_app as create_base_app, populate_data, \ add_context_processors -def create_app(config): +def create_app(config, register_blueprint=True): app = create_base_app(config) app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://root@localhost/flask_security_test' @@ -50,7 +50,8 @@ def before_first_request(): db.create_all() populate_data() - app.security = Security(app, SQLAlchemyUserDatastore(db, User, Role)) + app.security = Security(app, SQLAlchemyUserDatastore(db, User, Role), + register_blueprint=register_blueprint) add_context_processors(app.security) From 8bdd464239a3bd195007c47b0f00ac439aeaa07b Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Sun, 16 Sep 2012 16:05:06 -0400 Subject: [PATCH 219/234] Add form to namespace --- flask_security/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/__init__.py b/flask_security/__init__.py index 56429a99..756d89ac 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -17,7 +17,7 @@ from .decorators import auth_token_required, http_auth_required, \ login_required, roles_accepted, roles_required from .forms import ForgotPasswordForm, LoginForm, RegisterForm, \ - ResetPasswordForm, PasswordlessLoginForm + ResetPasswordForm, PasswordlessLoginForm, ConfirmRegisterForm 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 From 96f2be056d6fc232040c4d3ec7b408e47452432b Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Sun, 16 Sep 2012 16:05:24 -0400 Subject: [PATCH 220/234] Move anonymous_user_required to decorators --- flask_security/decorators.py | 18 ++++++++++++++++-- flask_security/utils.py | 1 + flask_security/views.py | 5 ++--- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 57095cbe..32e3409d 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -52,11 +52,16 @@ def _check_token(): try: data = serializer.loads(token) - user = _security.datastore.find_user(id=data[0]) - return utils.md5(user.password) == data[1] except: return False + user = _security.datastore.find_user(id=data[0]) + + if utils.md5(user.password) == data[1]: + app = current_app._get_current_object() + identity_changed.send(app, identity=Identity(user.id)) + return True + def _check_http_auth(): auth = request.authorization or dict(username=None, password=None) @@ -156,3 +161,12 @@ def decorated_view(*args, **kwargs): return _get_unauthorized_view() return decorated_view return wrapper + + +def anonymous_user_required(f): + @wraps(f) + def wrapper(*args, **kwargs): + if current_user.is_authenticated(): + return redirect(utils.get_url(_security.post_login_view)) + return f(*args, **kwargs) + return wrapper diff --git a/flask_security/utils.py b/flask_security/utils.py index 17511329..73ed4225 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -89,6 +89,7 @@ def get_hmac(password): h = hmac.new(_security.password_salt, password, hashlib.sha512) return base64.b64encode(h.digest()) + def verify_password(password, password_hash): return _pwd_context.verify(get_hmac(password), password_hash) diff --git a/flask_security/views.py b/flask_security/views.py index 260d5ae1..731b9213 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -16,7 +16,7 @@ from .confirmable import send_confirmation_instructions, \ confirm_user, confirm_email_token_status -from .decorators import login_required +from .decorators import login_required, anonymous_user_required from .forms import LoginForm, ConfirmRegisterForm, RegisterForm, \ ForgotPasswordForm, ResetPasswordForm, SendConfirmationForm, \ PasswordlessLoginForm @@ -26,8 +26,7 @@ send_reset_password_instructions, update_password from .registerable import register_user from .utils import get_url, get_post_login_redirect, do_flash, \ - get_message, login_user, logout_user, anonymous_user_required, \ - url_for_security as url_for + get_message, login_user, logout_user, url_for_security as url_for # Convenient references From 1f8fb487276feb693158b5f21ba572f8b2915186 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Sun, 16 Sep 2012 16:43:28 -0400 Subject: [PATCH 221/234] a bit of code polish and an attempt to speed up the tests --- flask_security/utils.py | 14 +-------- flask_security/views.py | 2 +- tests/functional_tests.py | 59 ++++++++++++++++++++++++----------- tests/test_app/__init__.py | 22 +++++++------ tests/test_app/mongoengine.py | 2 +- tests/test_app/sqlalchemy.py | 2 +- 6 files changed, 58 insertions(+), 43 deletions(-) diff --git a/flask_security/utils.py b/flask_security/utils.py index 73ed4225..9b444085 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -12,13 +12,10 @@ import base64 import hashlib import hmac -import os from contextlib import contextmanager from datetime import datetime, timedelta -from functools import wraps -from flask import url_for, flash, current_app, request, session, redirect, \ - render_template +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 @@ -26,7 +23,6 @@ from itsdangerous import BadSignature, SignatureExpired from werkzeug.local import LocalProxy -from .core import current_user from .signals import user_registered, reset_password_instructions_sent, \ login_instructions_sent @@ -38,14 +34,6 @@ _pwd_context = LocalProxy(lambda: _security.pwd_context) -def anonymous_user_required(f): - @wraps(f) - def wrapper(*args, **kwargs): - if current_user.is_authenticated(): - return redirect(get_url(_security.post_login_view)) - return f(*args, **kwargs) - return wrapper - def login_user(user, remember=True): """Performs the login and sends the appropriate signal.""" diff --git a/flask_security/views.py b/flask_security/views.py index 731b9213..33e6ab9d 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -174,7 +174,7 @@ def confirm_email(token): """View function which handles a email confirmation request.""" expired, invalid, user = confirm_email_token_status(token) - + print expired, invalid, user if invalid: do_flash(*get_message('INVALID_CONFIRMATION_TOKEN')) if expired: diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 7376f872..eb0c5c49 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -199,11 +199,22 @@ def test_token_loader_does_not_fail_with_invalid_token(self): self.assertNotIn('BadSignature', r.data) -class ConfiguredSecurityTests(SecurityTest): +class ConfiguredPasswordHashSecurityTests(SecurityTest): AUTH_CONFIG = { 'SECURITY_PASSWORD_HASH': 'bcrypt', 'SECURITY_PASSWORD_SALT': 'so-salty', + 'USER_COUNT': 1 + } + + def test_authenticate(self): + r = self.authenticate(endpoint="/login") + self.assertIn('Home Page', r.data) + + +class ConfiguredSecurityTests(SecurityTest): + + AUTH_CONFIG = { 'SECURITY_REGISTERABLE': True, 'SECURITY_LOGOUT_URL': '/custom_logout', 'SECURITY_LOGIN_URL': '/custom_login', @@ -265,6 +276,7 @@ class BadConfiguredSecurityTests(SecurityTest): AUTH_CONFIG = { 'SECURITY_PASSWORD_HASH': 'bcrypt', + 'USER_COUNT': 1 } def test_bad_configuration_raises_runtimer_error(self): @@ -273,7 +285,8 @@ def test_bad_configuration_raises_runtimer_error(self): class RegisterableTests(SecurityTest): AUTH_CONFIG = { - 'SECURITY_REGISTERABLE': True + 'SECURITY_REGISTERABLE': True, + 'USER_COUNT': 1 } def test_register_valid_user(self): @@ -286,7 +299,8 @@ def test_register_valid_user(self): class ConfirmableTests(SecurityTest): AUTH_CONFIG = { 'SECURITY_CONFIRMABLE': True, - 'SECURITY_REGISTERABLE': True + 'SECURITY_REGISTERABLE': True, + 'USER_COUNT': 1 } def test_login_before_confirmation(self): @@ -345,7 +359,8 @@ class ExpiredConfirmationTest(SecurityTest): AUTH_CONFIG = { 'SECURITY_CONFIRMABLE': True, 'SECURITY_REGISTERABLE': True, - 'SECURITY_CONFIRM_EMAIL_WITHIN': '1 seconds' + 'SECURITY_CONFIRM_EMAIL_WITHIN': '1 milliseconds', + 'USER_COUNT': 1 } def test_expired_confirmation_token_sends_email(self): @@ -355,7 +370,7 @@ def test_expired_confirmation_token_sends_email(self): self.register(e) token = registrations[0]['confirm_token'] - time.sleep(3) + time.sleep(1.25) with self.app.extensions['mail'].record_messages() as outbox: r = self.client.get('/confirm/' + token, follow_redirects=True) @@ -372,7 +387,8 @@ class LoginWithoutImmediateConfirmTests(SecurityTest): AUTH_CONFIG = { 'SECURITY_CONFIRMABLE': True, 'SECURITY_REGISTERABLE': True, - 'SECURITY_LOGIN_WITHOUT_CONFIRMATION': True + 'SECURITY_LOGIN_WITHOUT_CONFIRMATION': True, + 'USER_COUNT': 1 } def test_register_valid_user_automatically_signs_in(self): @@ -441,7 +457,7 @@ class ExpiredResetPasswordTest(SecurityTest): AUTH_CONFIG = { 'SECURITY_RECOVERABLE': True, - 'SECURITY_RESET_PASSWORD_WITHIN': '1 seconds' + 'SECURITY_RESET_PASSWORD_WITHIN': '1 milliseconds' } def test_reset_password_with_expired_token(self): @@ -451,7 +467,7 @@ def test_reset_password_with_expired_token(self): follow_redirects=True) t = requests[0]['token'] - time.sleep(2) + time.sleep(1) r = self.client.post('/reset/' + t, data={ 'password': 'newpassword', @@ -464,7 +480,8 @@ def test_reset_password_with_expired_token(self): class TrackableTests(SecurityTest): AUTH_CONFIG = { - 'SECURITY_TRACKABLE': True + 'SECURITY_TRACKABLE': True, + 'USER_COUNT': 1 } def test_did_track(self): @@ -485,7 +502,7 @@ def test_did_track(self): class PasswordlessTests(SecurityTest): AUTH_CONFIG = { - 'SECURITY_PASSWORDLESS': True, + 'SECURITY_PASSWORDLESS': True } def test_login_request_for_inactive_user(self): @@ -544,7 +561,8 @@ class ExpiredLoginTokenTests(SecurityTest): AUTH_CONFIG = { 'SECURITY_PASSWORDLESS': True, - 'SECURITY_LOGIN_WITHIN': '1 seconds' + 'SECURITY_LOGIN_WITHIN': '1 milliseconds', + 'USER_COUNT': 1 } def test_expired_login_token_sends_email(self): @@ -554,19 +572,19 @@ def test_expired_login_token_sends_email(self): self.client.post('/login', data=dict(email=e), follow_redirects=True) token = requests[0]['login_token'] - time.sleep(3) + time.sleep(1.25) with self.app.extensions['mail'].record_messages() as outbox: r = self.client.get('/login/' + token, follow_redirects=True) - self.assertEqual(len(outbox), 1) - self.assertIn(e, outbox[0].html) - self.assertNotIn(token, outbox[0].html) - expire_text = self.AUTH_CONFIG['SECURITY_LOGIN_WITHIN'] msg = self.app.config['SECURITY_MSG_LOGIN_EXPIRED'][0] % dict(within=expire_text, email=e) self.assertIn(msg, r.data) + self.assertEqual(len(outbox), 1) + self.assertIn(e, outbox[0].html) + self.assertNotIn(token, outbox[0].html) + class MongoEngineSecurityTests(DefaultSecurityTests): @@ -609,6 +627,7 @@ class AsyncMailTaskTests(SecurityTest): AUTH_CONFIG = { 'SECURITY_RECOVERABLE': True, + 'USER_COUNT': 1 } def setUp(self): @@ -620,12 +639,16 @@ def test_send_email_task_is_called(self): def send_email(msg): self.mail_sent = True - self.client.post('/reset', data=dict(email='joe@lp.com')) + self.client.post('/reset', data=dict(email='matt@lp.com')) self.assertTrue(self.mail_sent) class NoBlueprintTests(SecurityTest): + AUTH_CONFIG = { + 'USER_COUNT': 1 + } + def _create_app(self, auth_config): return super(NoBlueprintTests, self)._create_app(auth_config, False) @@ -635,6 +658,6 @@ def test_login_endpoint_is_404(self): def test_http_auth_without_blueprint(self): r = self._get('/http', headers={ - 'Authorization': 'Basic ' + base64.b64encode("joe@lp.com:password") + 'Authorization': 'Basic ' + base64.b64encode("matt@lp.com:password") }) self.assertIn('HTTP Authentication', r.data) diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py index 6ed14994..5cad502d 100644 --- a/tests/test_app/__init__.py +++ b/tests/test_app/__init__.py @@ -113,19 +113,23 @@ def create_roles(): ds.create_role(name=role) ds.commit() -def create_users(): - for u in (('matt@lp.com', 'password', ['admin'], True), - ('joe@lp.com', 'password', ['editor'], True), - ('dave@lp.com', 'password', ['admin', 'editor'], True), - ('jill@lp.com', 'password', ['author'], True), - ('tiya@lp.com', 'password', [], False)): - ds.create_user(email=u[0], password=encrypt_password(u[1]), +def create_users(count=None): + users = [('matt@lp.com', 'password', ['admin'], True), + ('joe@lp.com', 'password', ['editor'], True), + ('dave@lp.com', 'password', ['admin', 'editor'], True), + ('jill@lp.com', 'password', ['author'], True), + ('tiya@lp.com', 'password', [], False)] + count = count or len(users) + + for u in users[:count]: + pw = encrypt_password(u[1]) + ds.create_user(email=u[0], password=pw, roles=u[2], active=u[3]) ds.commit() -def populate_data(): +def populate_data(user_count=None): create_roles() - create_users() + create_users(user_count) def add_context_processors(s): @s.context_processor diff --git a/tests/test_app/mongoengine.py b/tests/test_app/mongoengine.py index eb61df46..2bbdd151 100644 --- a/tests/test_app/mongoengine.py +++ b/tests/test_app/mongoengine.py @@ -42,7 +42,7 @@ class User(db.Document, UserMixin): def before_first_request(): User.drop_collection() Role.drop_collection() - populate_data() + populate_data(app.config.get('USER_COUNT', None)) app.security = Security(app, MongoEngineUserDatastore(db, User, Role)) diff --git a/tests/test_app/sqlalchemy.py b/tests/test_app/sqlalchemy.py index 477bab3a..0cf2e9cf 100644 --- a/tests/test_app/sqlalchemy.py +++ b/tests/test_app/sqlalchemy.py @@ -48,7 +48,7 @@ class User(db.Model, UserMixin): def before_first_request(): db.drop_all() db.create_all() - populate_data() + populate_data(app.config.get('USER_COUNT', None)) app.security = Security(app, SQLAlchemyUserDatastore(db, User, Role), register_blueprint=register_blueprint) From 6c189f331fa489a6325f1122d019b2d431967702 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Sun, 16 Sep 2012 22:01:40 -0400 Subject: [PATCH 222/234] Allow users to be registered with JSON/ajax calls --- flask_security/views.py | 25 ++++++++++++++++++------- tests/functional_tests.py | 13 ++++++++++++- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/flask_security/views.py b/flask_security/views.py index 33e6ab9d..0deb68c0 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -96,22 +96,33 @@ def logout(): def register(): """View function which handles a registration request.""" - if _security.confirmable: - form = ConfirmRegisterForm() + if _security.confirmable or request.json: + form_class = ConfirmRegisterForm else: - form = RegisterForm() + form_class = RegisterForm + + if request.json: + form_data = MultiDict(request.json) + else: + form_data = None + + form = form_class(form_data) if form.validate_on_submit(): user = register_user(**form.to_dict()) + form.user = user if not _security.confirmable or _security.login_without_confirmation: after_this_request(_commit) login_user(user) - post_register_url = get_url(_security.post_register_view) - post_login_url = get_url(_security.post_login_view) + if not request.json: + post_register_url = get_url(_security.post_register_view) + post_login_url = get_url(_security.post_login_view) + return redirect(post_register_url or post_login_url) - return redirect(post_register_url or post_login_url) + if request.json: + return _render_json(form) return render_template('security/register_user.html', register_user_form=form, @@ -174,7 +185,7 @@ def confirm_email(token): """View function which handles a email confirmation request.""" expired, invalid, user = confirm_email_token_status(token) - print expired, invalid, user + if invalid: do_flash(*get_message('INVALID_CONFIRMATION_TOKEN')) if expired: diff --git a/tests/functional_tests.py b/tests/functional_tests.py index eb0c5c49..15958259 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -117,7 +117,9 @@ def test_multiple_role_required(self): def test_ok_json_auth(self): r = self.json_authenticate() - self.assertIn('"code": 200', r.data) + data = json.loads(r.data) + self.assertEquals(data['meta']['code'], 200) + self.assertIn('authentication_token', data['response']['user']) def test_invalid_json_auth(self): r = self.json_authenticate(password='junk') @@ -250,6 +252,15 @@ def test_register(self): r = self._post('/register', data=data, follow_redirects=True) self.assertIn('Post Register', r.data) + def test_register_json(self): + r = self._post('/register', + data='{ "email": "dude@lp.com", "password": "password" }', + content_type='application/json') + data = json.loads(r.data) + print data + self.assertEquals(data['meta']['code'], 200) + self.assertIn('authentication_token', data['response']['user']) + def test_register_existing_email(self): data = dict(email='matt@lp.com', password='password', From a0ed846a59b8f24a93e9daed8752523a42827d6e Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Sun, 16 Sep 2012 22:11:49 -0400 Subject: [PATCH 223/234] Remove print statement --- tests/functional_tests.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 15958259..9ac92c67 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -257,7 +257,6 @@ def test_register_json(self): data='{ "email": "dude@lp.com", "password": "password" }', content_type='application/json') data = json.loads(r.data) - print data self.assertEquals(data['meta']['code'], 200) self.assertIn('authentication_token', data['response']['user']) From 857f13574871639f3eff5c548c7de390831f123f Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Sun, 16 Sep 2012 22:15:25 -0400 Subject: [PATCH 224/234] Add mongodb service to Travis --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index 6d1dd825..17440a23 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,6 +12,8 @@ install: before_script: - mysql -e 'create database flask_security_test;' +services: mongodb: + script: nosetests branches: From 90b4c5845774a4809be14a767aee984afbfd6e15 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Sun, 16 Sep 2012 22:23:04 -0400 Subject: [PATCH 225/234] Fix build, hopefully --- flask_security/views.py | 2 +- tests/test_app/mongoengine.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/flask_security/views.py b/flask_security/views.py index 0deb68c0..912cb38b 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -104,7 +104,7 @@ def register(): if request.json: form_data = MultiDict(request.json) else: - form_data = None + form_data = request.form form = form_class(form_data) diff --git a/tests/test_app/mongoengine.py b/tests/test_app/mongoengine.py index 2bbdd151..52377f29 100644 --- a/tests/test_app/mongoengine.py +++ b/tests/test_app/mongoengine.py @@ -16,9 +16,11 @@ def create_app(config): app = create_base_app(config) - app.config['MONGODB_DB'] = 'flask_security_test' - app.config['MONGODB_HOST'] = 'localhost' - app.config['MONGODB_PORT'] = 27017 + app.config['MONGODB_SETTINGS'] = dict( + db='flask_security_test', + host='localhost', + port=27017 + ) db = MongoEngine(app) From 826cc0d685140f89b5b3579d28bda7ece60da850 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Sun, 16 Sep 2012 22:27:21 -0400 Subject: [PATCH 226/234] Change services --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 17440a23..41123619 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,7 +12,8 @@ install: before_script: - mysql -e 'create database flask_security_test;' -services: mongodb: +services: + - mongodb script: nosetests From fca7120210cf082e51759eac157b17c1dd795fd8 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Sun, 16 Sep 2012 23:00:10 -0400 Subject: [PATCH 227/234] Get rid of unnecessary anonymous_user_required decorators --- flask_security/views.py | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/flask_security/views.py b/flask_security/views.py index 912cb38b..ae0dc441 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -35,7 +35,7 @@ _datastore = LocalProxy(lambda: _security.datastore) -def _render_json(form): +def _render_json(form, include_auth_token=True): has_errors = len(form.errors) > 0 if has_errors: @@ -43,8 +43,10 @@ def _render_json(form): response = dict(errors=form.errors) else: code = 200 - response = dict(user=dict(id=str(form.user.id), - authentication_token=form.user.get_auth_token())) + response = dict(user=dict(id=str(form.user.id))) + if include_auth_token: + token = form.user.get_auth_token() + response['user']['authentication_token'] = token return jsonify(dict(meta=dict(code=code), response=response)) @@ -92,7 +94,6 @@ def logout(): get_url(_security.post_logout_view)) -@anonymous_user_required def register(): """View function which handles a registration request.""" @@ -129,14 +130,17 @@ def register(): **_ctx('register')) -@anonymous_user_required def send_login(): """View function that sends login instructions for passwordless login""" - - form = PasswordlessLoginForm() + if request.json: + form = PasswordlessLoginForm(MultiDict(request.json)) + else: + form = PasswordlessLoginForm() if form.validate_on_submit(): send_login_instructions(form.user) + if request.json: + return _render_json(form, False) do_flash(*get_message('LOGIN_EMAIL_SENT', email=form.user.email)) return render_template('security/send_login.html', @@ -165,14 +169,17 @@ def token_login(token): return redirect(get_post_login_redirect()) -@anonymous_user_required def send_confirmation(): """View function which sends confirmation instructions.""" - - form = SendConfirmationForm() + if request.json: + form = SendConfirmationForm(MultiDict(request.json)) + else: + form = SendConfirmationForm() if form.validate_on_submit(): send_confirmation_instructions(form.user) + if request.json: + return _render_json(form, False) do_flash(*get_message('CONFIRMATION_REQUEST', email=form.user.email)) return render_template('security/send_confirmation.html', @@ -205,14 +212,18 @@ def confirm_email(token): get_url(_security.post_login_view)) -@anonymous_user_required def forgot_password(): """View function that handles a forgotten password request.""" - form = ForgotPasswordForm() + if request.json: + form = ForgotPasswordForm(MultiDict(request.json)) + else: + form = ForgotPasswordForm() if form.validate_on_submit(): send_reset_password_instructions(form.user) + if request.json: + return _render_json(form, False) do_flash(*get_message('PASSWORD_RESET_REQUEST', email=form.user.email)) return render_template('security/forgot_password.html', From 338f04b2f2f14fbaf21cedab60e14acbe1173664 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Sun, 16 Sep 2012 23:01:35 -0400 Subject: [PATCH 228/234] polish --- flask_security/views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flask_security/views.py b/flask_security/views.py index ae0dc441..73f80714 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -132,6 +132,7 @@ def register(): def send_login(): """View function that sends login instructions for passwordless login""" + if request.json: form = PasswordlessLoginForm(MultiDict(request.json)) else: @@ -151,6 +152,7 @@ def send_login(): @anonymous_user_required def token_login(token): """View function that handles passwordless login via a token""" + expired, invalid, user = login_token_status(token) if invalid: @@ -171,6 +173,7 @@ def token_login(token): def send_confirmation(): """View function which sends confirmation instructions.""" + if request.json: form = SendConfirmationForm(MultiDict(request.json)) else: From e423390050744cfe37134d82637ac5aae3535e25 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Tue, 18 Sep 2012 23:49:44 -0400 Subject: [PATCH 229/234] Simplify login form to only include one relevant error message --- flask_security/forms.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/flask_security/forms.py b/flask_security/forms.py index 38957fcf..ed39e9e1 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -131,9 +131,10 @@ def validate(self): return True -class LoginForm(Form, UserEmailFormMixin, PasswordFormMixin, NextFormMixin): +class LoginForm(Form, NextFormMixin): """The default login form""" - + email = TextField('Email Address', validators=[Email()]) + password = PasswordField('Password') remember = BooleanField("Remember Me") submit = SubmitField("Login") @@ -143,6 +144,11 @@ def __init__(self, *args, **kwargs): def validate(self): if not super(LoginForm, self).validate(): return False + self.user = _datastore.find_user(email=self.email.data) + + if self.user is None: + self.email.errors.append('Specified user does not exist') + return False if not verify_password(self.password.data, self.user.password): self.password.errors.append('Invalid password') return False From e1dbed816cd0a67243dca30c04df760535f3487f Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 19 Sep 2012 01:22:09 -0400 Subject: [PATCH 230/234] Simplify login form a bit --- flask_security/forms.py | 17 +++++++++++++---- flask_security/script.py | 21 ++++++++++++--------- tests/functional_tests.py | 4 ---- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/flask_security/forms.py b/flask_security/forms.py index ed39e9e1..a50f5767 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -42,8 +42,9 @@ def valid_user_email(form, field): class Form(BaseForm): def __init__(self, *args, **kwargs): - super(Form, self).__init__(csrf_enabled=not current_app.testing, - *args, **kwargs) + kwargs.setdefault('csrf_enabled', not current_app.testing) + super(Form, self).__init__(*args, **kwargs) + class EmailFormMixin(): email = TextField("Email Address", @@ -133,7 +134,7 @@ def validate(self): class LoginForm(Form, NextFormMixin): """The default login form""" - email = TextField('Email Address', validators=[Email()]) + email = TextField('Email Address') password = PasswordField('Password') remember = BooleanField("Remember Me") submit = SubmitField("Login") @@ -142,8 +143,16 @@ def __init__(self, *args, **kwargs): super(LoginForm, self).__init__(*args, **kwargs) def validate(self): - if not super(LoginForm, self).validate(): + super(LoginForm, self).validate() + + if self.email.data.strip() == '': + self.email.errors.append('Email not provided') return False + + if self.password.data.strip() == '': + self.email.errors.append('Password not provided') + return False + self.user = _datastore.find_user(email=self.email.data) if self.user is None: diff --git a/flask_security/script.py b/flask_security/script.py index 7c798f28..9c9a2469 100644 --- a/flask_security/script.py +++ b/flask_security/script.py @@ -43,7 +43,6 @@ class CreateUserCommand(Command): Option('-e', '--email', dest='email', default=None), Option('-p', '--password', dest='password', default=None), Option('-a', '--active', dest='active', default=''), - Option('-r', '--roles', dest='roles', default=''), ) @commit @@ -52,16 +51,20 @@ def run(self, **kwargs): ai = re.sub(r'\s', '', str(kwargs['active'])) kwargs['active'] = ai.lower() in ['', 'y', 'yes', '1', 'active'] - # sanitize role input a bit - ri = re.sub(r'\s', '', kwargs['roles']) - kwargs['roles'] = [] if ri == '' else ri.split(',') - kwargs['password'] = encrypt_password(kwargs['password']) + from flask_security.forms import ConfirmRegisterForm + from werkzeug.datastructures import MultiDict - _datastore.create_user(**kwargs) + form = ConfirmRegisterForm(MultiDict(kwargs), csrf_enabled=False) - print 'User created successfully.' - kwargs['password'] = '****' - pprint(kwargs) + if form.validate(): + kwargs['password'] = encrypt_password(kwargs['password']) + _datastore.create_user(**kwargs) + print 'User created successfully.' + kwargs['password'] = '****' + pprint(kwargs) + else: + print 'Error creating user' + pprint(form.errors) class CreateRoleCommand(Command): diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 9ac92c67..8f2a0ba2 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -49,10 +49,6 @@ def test_unprovided_password(self): r = self.authenticate(password="") self.assertIn("Password not provided", r.data) - def test_invalid_email(self): - r = self.authenticate(email="bogus") - self.assertIn("Invalid email address", r.data) - def test_invalid_user(self): r = self.authenticate(email="bogus@bogus.com") self.assertIn("Specified user does not exist", r.data) From 6b80aae7d1a88cf07306ea3e00f7708e299ee6d1 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 26 Sep 2012 16:25:22 -0400 Subject: [PATCH 231/234] Fix error --- flask_security/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_security/forms.py b/flask_security/forms.py index a50f5767..373debc8 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -150,7 +150,7 @@ def validate(self): return False if self.password.data.strip() == '': - self.email.errors.append('Password not provided') + self.password.errors.append('Password not provided') return False self.user = _datastore.find_user(email=self.email.data) From a269930ec34c3b6f8a34b9167cacd7650b20b908 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Wed, 10 Oct 2012 13:36:59 -0400 Subject: [PATCH 232/234] Update docs and tests --- .travis.yml | 4 +- artwork/logo-helmet.svg | 297 +++++++++++++++++++ docs/_static/logo-full.png | Bin 0 -> 10690 bytes docs/_static/logo-helmet.png | Bin 0 -> 10017 bytes docs/_templates/sidebarintro.html | 17 ++ docs/_templates/sidebarlogo.html | 3 + docs/api.rst | 21 -- docs/conf.py | 8 +- docs/contents.rst.inc | 1 - docs/index.rst | 31 +- docs/overview.rst | 33 --- setup.cfg | 2 +- setup.py | 5 +- tests/configured_tests.py | 463 ++++++++++++++++++++++++++++++ tests/functional_tests.py | 445 +--------------------------- 15 files changed, 824 insertions(+), 506 deletions(-) create mode 100644 artwork/logo-helmet.svg create mode 100644 docs/_static/logo-full.png create mode 100644 docs/_static/logo-helmet.png create mode 100644 docs/_templates/sidebarintro.html create mode 100644 docs/_templates/sidebarlogo.html delete mode 100644 docs/overview.rst create mode 100644 tests/configured_tests.py diff --git a/.travis.yml b/.travis.yml index 41123619..42c3b479 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,8 +6,8 @@ python: install: - pip install . --quiet --use-mirrors - - "if [[ $TRAVIS_PYTHON_VERSION != '2.7' ]]; then pip install importlib simplejson --quiet --use-mirrors; fi" - - pip install nose Flask-SQLAlchemy Flask-MongoEngine Flask-Mail py-bcrypt MySQL-python --quiet --use-mirrors + - "if [[ $TRAVIS_PYTHON_VERSION != '2.7' ]]; then pip install importlib --quiet --use-mirrors; fi" + - pip install nose simplejson Flask-SQLAlchemy Flask-MongoEngine Flask-Mail py-bcrypt MySQL-python --quiet --use-mirrors before_script: - mysql -e 'create database flask_security_test;' diff --git a/artwork/logo-helmet.svg b/artwork/logo-helmet.svg new file mode 100644 index 00000000..2799f1fc --- /dev/null +++ b/artwork/logo-helmet.svg @@ -0,0 +1,297 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/_static/logo-full.png b/docs/_static/logo-full.png new file mode 100644 index 0000000000000000000000000000000000000000..724fd06c3e00b655487ca55746503a6b284b3874 GIT binary patch literal 10690 zcmaKScOaYX_qPtMYL(hb5u4WDD@IV-+IxhOpc=7BgwWcX>d;nEI?x(T)u<4)LX9dl zqOlbru_{Kzcrdj(D1;oo?3Kt zr-XcL96}sSZ)rgM;WF;WGBQYbz>zi`ot6$Vz#Rhf3=#D3^!D-77FxwN2?_c@wT0}J zP324jK%RGej8Q?J)+jR@2nq&KhYINc1+|bGM*wio5O+Z&+}AHy1F0?a7gyux`1rD{ zklAQWZ%gS=(s)z#IHF%%S} zk0hjn!~H_skXc`1=VSBf5L|hlXei9WnjK1$e;!!1@LM8>XXx z$s*kYWaVY#j$QhXpsDHqUlb1iAL-x_E6@Ms`~L(Ewh0gLl(q5<_74q$91Wb8@Uf`? z4N#D$dx(FKjlaL|f1+q{$3MhB_>O;oAjn!-(9|8`<9B@iSKicA!^AH*#N7|#X=0!) zbc7(|;{(-D)YmgGG%(PY(^rs}mp4>U(>Ku9Q&dnil#`QFQIS{skFJ3~BoyxH7xEun z=>O`1{;TeBD!>DdEE{+R`Gk2wje`8)f`47s@cHjt)c-5qzq-)>&PDIP>dGFGkv$&R z|7)QCbLl91jt~DQZI4d=C-FV~j-%Lxc9-)A!vdA53iIXlyxHIr?s$63z|u<`a}qzxfrx&;OZLC| zmt%?~!e(Oxlg3l-`GT5gO}yc6--=z`@S1OQd}e!yFnh7MSkYA3_I_U#YBlh*?e_H#yw$halu?^5iA@JR!9_#5=xMA*z>YpD}KiX|FKk z_rPcD-~TGo_iwX65=-l1vou=>h4?5h-){s@`~j3A1+nL)Rbk3)8DfU;HI^rkvXDgH zh^H8MI@r%wZ*qeTvLVGHx%=1t4OTR>@-Qd4FydYcW;`9vLe3+WoMiD>W8K$myV4Lo za+y|)(J5hx`+%+XrfG5$+X>D16_2vQ%0KZAvQyRU=wmK`uVqqe6Ognk-o9T6;%%r6 zGleh#$fW#m|Hzd$MR0QrKepOKI})M1br%Del1J;bO{Gg6M7{M`Gpg1>pj8@T*Ur?m zC8RD;H~+$kB$VajKxtRr>oezLmGMAN-(kWxJij~TI)2MSPhD8Wmv##h!9z>IY=k(| zF4KxIZl#1=7BbJ6VLM+#LM?AZA|_fiZ7~WzahKwc|7LSSjfJ!*e1zR8mxLR)wb6T& z=S#`OAAo#!wyt3&G)26|L{v@_*~u89t07<1p(upeDiDD=xG>EiJE86YYU)!Bdx?Ry zp-!#WZWpI970z@dZ%R!F4tMXIO#vPSAdV&VCX!44B49M)GIoIAMs*9Qz_8h9`PrQ6 zY|9%dICQ7{kYcpAAuJc_FI%CR@t9l(W+4SMVlZ0g>acZP6Jux8SOrAg-td=mJBm-I ztMch_T-z8cmH35K*i(Cm0;x=251bCqoJjhx6eUT#CxNx4pFB)qqRk-I4#34itAdbi z<*?_3&2Wju-`Wr^o``b7_umyBpk|ZXL|N-g=lT(JON%m-d?Q zD^0O2!5?(5%)M+IH0@QUIOgoTkJEhrf%uACy~Y{^Dl3-l(lQca9OgT)qtxp#IF?B_ zBot*H6MQAM%kN1^#{*?mc*8V2HCbFce>Lj*KHbdD#%KP@DjDLv(ID89mL;c<@PIC6 zGt&6v{bH6pt*OljXiwM_z@Tm?62sqZh=FnYza;(5pbsfP7hL z4s-Tzb7J`AMXtYi-y(joEDz+h`YIjm=6k|R#Oj58;eP58{mMHX&<@~2Ltcsw^!y}9 zrTkJ_o#DcK*m!gZtKR)PkJ9zF0)H{R{>#l++EAeDZG)a~BGV&#_2Pw91Us3DjU!^# z1GZM^FY~hqFghd5F)?{1U zLIps$T(8B!`{`%jRy65xH=6k7q;YNUG5KDt$;CfY{uxc_o{-vN`Q5(TG;X`r%$td6bM}zS?B?R$HH|(m5t#QVVNm>c0b7R6o+da_T z9l9%Yy57lZ7-ySwbx3CL5VTeZoGSFtzW+Yd?7INczIxgAnm#u~S{TI~ZQ+}z{|`Z% zY`^ue6oP&S3tAw1C;KXkHUlk;OE;?2!Jj~DONay%$y~|)GitQWQ#gq&k!b7FOx@f) zBAG3_b`ps6^qkY>w>ln&QlSUM%&@Vn!F%;#3nqt`BS!Fh7xOE(lT0bPLLFvO2ZB#< zA}3M=b2_Cze&r>cHxRhX8s!wfwt-ap>()DiMnu2mDcFxEY5QZ-=#p7PJ)>5f%7ZWW zSGbIStGSekU#Q;L;+y%bi6>~L=SkIgj&FzmtNP_f;%TNbcTMLpVsYV@^<+FyP-QRz zcfb$ZW#KnMT>PYkMKLT~$nVWe0Z$@h2o&(~9M0-W1yZXFSVWWzn5n9jO4R*_RA$^I@tPBzKPIrj=&@S=?e^uJL@`EX%i{ zuO8TRm*&=lf0uzf;$gTBxX>vi7LZ%qYTIMBk#&57GX@(@7Nj+PnN{h}4XXt_7k-p? zKW8;h%jGYkeOBjvek?hC`3|L(za|+Kok;2)4~XtADK zH~-;q^YDs2EMy;T33olr8zyrB+Lrg8D-92CFfR0;QOe~%OkbGC;43Z>y=0WuM81jbHF9k1DlF8@1yWw~WoKl*Z8gN2g`Av#{?tl1 zH#)OB{rfw6OoH6+`76l7Z^~OmCyB@;LMS?P-r7SO{f*t>M^3$uiK{)=bD@G>W%Q7+ zACpfW{p8D$&#VedBblzwj3O$DxDm7?Y`4A{)iA;xk%z&~@u_M--@~+4nOTUTv-KE-*Di0h8^zAgO6}m)_uCzY7PfOP(-H_h#D#gn%4AyM#jkc!LS%eD9%%mq z!=}I}*faZFlv`TwX^wR3gk=e~otF_{_OjtpSA(h83xBfqc^uF5&!1BiKT0P4V(*`W z9ByK10P(Mta`#c(dY#Y48Zk4`@QJq4n?h)N?$B!|gB5pU)S_7lU>W z$botVTd{R_4FMrzXCOY+n zhd!$#%r{!wdw40ls%ht@!i(>Ta;P1xk5Y511)R=cUizHFyg*_!7glUd1;WgQ1+LgD zKV^#x)AhQvNrxws3$sjP2Lo#kH|4n*xg9F{x<*7$yN zyHvdGqPfiyu)%#s|_W&QW_f&x8RZg!_>GmL}KTR_^#ma zglNqnrG}?AeK*XQRM$fI(T?AJ|4rM5^zkAKU`Vu=Hs8I$a!$vUnL(1->ld>H^+d?< zm)(X$&#|p)kB34Qq(9a83Fp{9Y6QYI(&5Nbc1r86UoLli$~nO6t+JrV@zr)t7objS z4dzS$7e`di&~2F-OLv+wKPN_YfFS{E{MgWqG+V%;pWsSG4-t|tR>pQ}_7cK99s*c? zj&~E>U~*h%FBA2AnLR$L!_?@Hmx9L8uJurca1Zn(P(C`=qNQbNylsnUNUNqTP96xU zs25%2^Pvd2k4x(>-E#7hJ$h2PR*?^kyZfMj*BImJQXhUF!<-7PMbu+XrGYD>u`ZL= zp*1RDNtoJ=tP<(o3f`zk{MuM`4#P zU6hRO>f#2iHd(rF;C#JPL)jTxSXNza$Wk{8;+KSyG;4+Mbx^IU_$XGwAYuE-1L=hG z%Qkq7H1%0!QAf;b2H#bBX)>@!ttZ#t-|cF1q<}a%M8GbHPnP_8PdRgZ*0t-;zByFw z?QcFd!}`)5cD0&rwM?h{3ia}*5FjKn|=tmqL+fRGIY~iU=mwQQ0HIUky5CcFq(ZOvpcNrS7qit(I7pwJ8EMxQ#l7IKcw9VN!@P-2jPzXlfM+aj7aI-m|4x*aXHS=hRO z8Cm(g7hkj-KGq-j?O<%WLLJ)X_;`KOo>#Mnunjq=$wj%UT-F>VT-_AGNASPVc@;KU zYE=$RKFdS2T+_{q*zCMMA*0(t%VPwe2Vm3T+aN9KG z95G>n<^59=?c4x1|2Bs4c0QqsT%Wv-Pl;cR4SJR$-qn1QWo(LP-*_k$naR}zI~b;C z_h!G=KA+K^o;-5C)23(j0VefV`U2_Da$;@+AI9NKTj7Iv^U~6c#a2Cd8h#LHXy2r1 za50AKvn4fQb6#sqPTT`uC(_>d1D?$6=6UXOd;GU2uTj(BoIsju-y-WwI?YuWFu}JL zE-w?6BVI?r$_9=~298}=txr@U&d4fBwXCglRxX~po?70lI=n&aSh9wW8PbDg%oMiH zK>A9a3Oc?~o$0Wm+PYG!Jx)KW^gQ+O#Obb>j&-x7rpn296XNB3LTmn%Vr*J`-($`l>u)whri7k*q7JjIAjqxhS(}^-zyyP6S)xa`T6D8XDKPTz|oJs-?AshKx|zb?C~*Z zei&2WRoO=)ic8N;G;=mIFFsHo%ch9tL^4WFj?`v#i|wL~oX6T~`&#Az+nUrDDxz*i zpZfY7*2;zTlYwjS(ZRsh$Hm-OdwTkFjHUu$P=D=f)rbPlyX%$A{i+q_AA?WOuhw<2 zMayIv!|g1n?cqO(m z6G&++iOrG#jyURVB9e#!Jt!oOZF?K~rk{hp*W(7j0!areI|N z**$BHOXzfLVM5YGjdqS^x)P_cC_EkQ(X;q!=}{g zQw6Cv!}}Vgh89zGOXJF>$9dD2oiV&xaU_AZCl3IxWo!yft!xU)iK+J#u9d{sMd?C* zzAO+3iYj8gYOU_aAmg#)kX-rWDFQf*;n{=p)tzpIm-N!^ zjhKA~Uih?mo4-F1*Z|>~tcv}aH&|8UMvtR*@X!F6XseSzHlF#j4FZAjn34BWmqS+V zU5sC~i~-sOWR@H?qUt`uh%C2z$ksKBR`vKfWwLe#T3VJX-r?<3H#N(W>Rvp#2`~*9 zW*n@J$d2udD8kq$^^R$`6CCjqa7r&82&2@USZNK@SVwGrwiF9eOGmS}U>n{0lJCu4 zyV}5OpYo;*n%T--MrBWk%NALgG&s^r$x_0^fwySEu% zdl{R}tSLgbAurUukRE87^LUykd8s+{{6k0W+TyDR+F!_+D%Xco4}SC5B^$iS6hE|> z&TnVWG}4?qGq14a*$2s=6BSXtkV(^2b@?{#I>5hnc7j79lN9T)>)i!gJDpY?q{>Yb zKr`n{OwWP7=8W)STL{yqey)07rpaf9vnGu`74Nz>t(&uaom1iU<;Nb2n`aF7({o_d zyj$eDZF^_3$%jwYeivjs6})Z&`6DuW*W)n7muAFN`feJptcE}MF=Z=a{;Q7vLY_o) zYF)fx!ClIn$H;C2mb~r2ymP8r_^ezMk}KMeqC3g!g&gsrJQP+KuIm{VEj4nxm9ysO zBFQNLUwvn6Go}J-RLjwykaQ zb|`gbH3VxI{=tFy*PF;r9w#UH{Me6;Rvq0>V7MZKq|n_qu5j|ZU-GH=qc^#FS9mx> zW)3MLNoEZGb@+LCY31bi1*0sZ;yAR68KGc3t!WilX&m}8M^=DWBpeQN9@F~yn0a~L zof(slbIo8SYtzyQn-LO;K}8trbaV>=4!*msiT^e<5BFgo#eJR>T!9fU>Z7T;7N(cP{45VLlW-EFSpvO7e~UpWqpSX z1R75CaqWQYGOOV^mo`so%ZY(XJdB*MrJN`tq{t@G+#N-qKYtOMA7pJ(;yVrJ|*wO50 z;cL5X)41`ns}hybq*-iepLgM-p7&|{Vt6uFhuso9 zXa%inWs5Wg9$k*yt}w7Qf15kc!+uV*|mHoh}pu-lWBj1w&+ zy&~~m>CdT2Ku9gn6!OGvSC{N9w4kV#hJ(5svugRy7Lf2~LoX>SQ1 z25yR3Q)CV&+fC>v(eT2+;ZTH3=0%fekdL|i&$zIv=0fR4l-7FM|iLR{@&Eyl5JyumbH0!Ut!Bx3bWvU`a&``0wSyqMpo;cn}Y95M5fAf)h_#Zn?jNv| zwj-?kPR9%5R|E*FXXL()s71ti>TUF$p5CUe7Fj4m{eun7o3Cv9XW=+JL|kMQ z^wXm&*6ckQl_j$KoT{Fzyq~+z|<_5f&g8*uDiFN_Af+3fN7M&#pX_lgwOynhQUw=Rz~%XLg>7 zcUq{rxzLTYMY@9G-G&q*oKJK-5)1MU1}@gVdA`X4u5Z6}8d#vA?=K{ovzcL*^l2ju z1Qx%|+_>f@n%nWN((=LfRY6bt7a9U3@A1#ve+9^`lwAI`$5*k)+oSHVuusw6nyHZO2l)@E1%qSXeR#_( zJ#2B&v#(;(y_}!*TE$TG_HgnSL(d3~SnOq5?oV8{P8l(?_Wg&pCULQbi!p3h)PRy< zy#RxW2O2U|mI)){$3E0`;L!an{*=))R|$bHt#vh-awHqd(?#S~{$~ba)U*Xx9~C;a z$};O_(LG>K@T356w9=58$*w~n19`CK(>YH>8y~sn z>!Z>&^vx&bP`gEmB%x5*U^m)X@JD)0e)~*jensel>q}QI>No8XLZ&Sx@3m`P2Wi#( z;vRi7aa$BZR8MkXj{_PWMi;L1C9|U+b6723PQ{h7E`*gr0ppd*jy$5^k1mm^8N~oU z!>WZKz79_Yoe?vk^cdpWX4b3~r+9bxGqGW3^-T1L{t`QSbG)yt{r85}qen8k0}cM> zkEF4lf}c`l@S&zeI1K(DCMe(+ehzx2h@2KZuz`+j~X67d|HU zvF2LtX<)uyd5vm|4+6y@^y5l?vdXj(bCvDFBu*668oetR)vU;Wr`2&wu@(1P`e#08 zt%Z+Lv|(m$0x+bGf23QIrb0$uoZH+onguTeXgd_FtCID97&Z(Se6;(86Fj&%$x*HB9bw>_1S)Mn=6xuyW)fJ{ z-b?)?gWBLa_J$$(m51ww+CHsua3k^7?YI(*%KGgTTvsyAtS7#Haq;yEvY{^SLc}KO zPYP6t<|_V7DU(f<&)N}gw(zo?i_|F=qVwdW>o4bQ^D;B)C2^+F2Dsa>oK63%nCV_9 z^heRQU$^3f7IVbIjE(QPV*`&&^8%iyr%9hS77!c7Ik_KwhKrn+MP6 zFb;kV?BT-JFM(ogsZu)x9)5&@l~B8W;VV^Hm~#xu@BYAgw`%eBSkc#w(8l`r{*-l?W`?DcRtsc zcE#l>-0r_$9U)$d_#KHsM`O&^ALDUw%4v-J4wjqPf7E)cFn>*YpFtvvOS~0v+kr{V zCk$K=+@04-Q#CD{TSq1IB$;(lq(Rg+DxsU5;~&h%<5%LJPfV7StgV zs_BhTg92#7TlNgycuq9rTme(x$gB-PPYql)L%N264+$3enephLHi4$^`s<>&AhImE zrK#1)ok~X2^Pr(ro^>yb31E%YwP3TmtCc_?#EH}fFKTB2a1S{pDj)ym+{!GD1EV@{ z68mII@6E_7brNYDwf8if2zj4KDvbBrhwP4C;y&{UM`h#d-)3?zdU3j6bKuy3a_G&NbMpQ$5p78cr<+`N zY2o}(&5VvE4A5ZmH-H96o-(o`ZJBB&~dtVm!?K>$r z7Dg1n^b>P7U|G}PIN)Jf3v&^vlW#V<&bKSO{IX>gbdWbP{;0rxRHhBMtQdUS&~47Z zt+u1G(-iAw2W9AAfb@j7{8Z#n%Xfi|#j~IRr)`ZZU94N;Odn<-+(s-XXY)q!(>S6ARh&EAe_mi=d>_)8DURwJt5pbL%Y4EIV2Db5%R(aViXNjO)qX4qz&f z%DwR|YYFWSQ?hyR<_fV0=N*ksa^(N=losy+uEprp29^!Jx;Pm0GT`QuzD(RkS;x)Q z27z}Q{O{-mYa${?98zWal3I#1&jKfMuz2vV;QEDd)0g2u-~xgh0D%otWT)j3rixBfwwq!>u($&{;@zAb4BM{O!dA3T5>f+Wy;5J{~Gk9}N zh>S~?c{lY`;N===VaSJ!E1wkHMmbC?~m61Ksr{Hw=(s?O{h&LB`;n3xH*+4eKK zc^RbCLaYV1FPu2Z#gFTbh(a*`BWTPj8o4Fh#l+3npI8HZF9J!bI?IkU@_FQDAIWb~ z%dC9%K}b;b!HNQAKl0pBt4aa&NmP9U?R2+hUa@rJ^v6)k7k|k0))KeVev6e%B9dpf zjvB-w$;7*ITr9=gcK7RJQ*gq_9=4-)oUn9?Pvgg4c^pliI2Wek0;-jq8K0m~Cf z6ju=+mv@!uEXHD3{Y11m28|-q?XK`cD8(MF1D*4R&MJ?z9A+@)NByw03ymBARI)-t z;tlHf&nj!n&h1u@?%cg%OlETLZFL7h!i3-S8_g z%2}<0BR8@h0D8M?-5-g~%3%P`Nrg9U1;_2_1)M6in+dIV>)Zr5oNMOj_7D4|eDiL9 ze$SrM)3+`d`0+xiu-ZrU;>>Fl`j6ZAyA*KS9i>>mK`- zMtaEJZ+|l->CN)5~=k$kX<1v1#>kW zEHX^AM{F|p%`M~2IEa~uasns(uPFR(UbS-w(*%zP){!C`COxK_ICZ|!!BD{IL!(Jq z?lw37!f}=v$Fhc{hECi+dM3|Y;X7)vU$#MS}N-crIaej8|5oz?JSZoRtS)aQ#_jAFw5jajK4Xl1_Vt|`>=t^<1EI(9C z`2Hy}%_4K{5B>n~wL0SHUlx6d-MeR6>K!`HG7AiEHntFg+r1=@Gc(SD%5<(ohhbYb z+uGHWA^{;@#;O+o?b#ktX*Gh3q{8dchJBB!Qcnm@amOJ7RL3Us?f)q!?8cV-*w(~I zFI?mzpMcKOAKST0uZ`aS^O%4_M=RCjVjiZAPZIK9T>jS*vwae{Bhg}2dlP|W05u$= zl>#oTZY|hhJe}-P!9Nfbt#1*-6Ls@w|sLlyi=CHM#vQ>9(#KfhGz42ueJQ* z!vfVU9!xbYzvUlxS8gL!^N#lXY@2=m^RvUjI@;@>JulT$ho`yezOfHj%Bp_G9Pg?$ NF*Gx%(7zq?e*joOt6l&A literal 0 HcmV?d00001 diff --git a/docs/_static/logo-helmet.png b/docs/_static/logo-helmet.png new file mode 100644 index 0000000000000000000000000000000000000000..b4904186195d7f503f77204537f398dd108be6bd GIT binary patch literal 10017 zcmaKSbyQUU_V!RxA~6g|hzQa(Gn8}=Fu>3y-JL@XDIg);UD90w0@5wrpa@6^C`xy~ zcz^eMfA1gfea~7a_Fj8G``NM9Ip=duxQdc20UjkD001C>%So#}oTDE0QJhB)$GkL! zzX}RBn2wvelck%d3DN=}VeVvVK?`>6kP7a(V ze{DEX4$cqW0D!O<%Gt!s&ccn>)WXWfQG{;4wUdt4#$1F>n->8_I7?Yr+sJt%E!4f0 zG|as1%mmEo#6)R@QGyQu4i;`Ev?vFAM^{0V2;IMM1s|^es=4TB|7GH4Cqnl>q;wD} zv{Ft;3tC=IFozkK4@}Dk;e>GW@bmMt(?Y=zFc%og1%Yxv_yxJ4f?zQ1zc;#vXh?HQ zK{aWaf5&=|MCh#D+?)luxI8^QIX$^Kosd>s5CH*!zZg&`$AbljtCypj35vtfmHrZzijguqoUqlmACwDgyx(BBJlY)cue_$P5|6Qhsf^nftoVg&J;J+#TV~9Zf z|4khn{$uUxre^WKeE*-ot{Ps>7F=o;u1@Yqvxml6(*F(REGUJvFmZE2YB)LB|5HU3 zYbQ4+S8FF{S}Ap2T7-$2jpJYWU-k%uAl%W_&BW2n0xm5=_kh4@V`DBT1AfKxO5ha~ zED4c;Kw!{UlG2hA+)!>97!2m)gS`63SK7(U-NC}q?H^zB|MlhmufBgr!NK_2u~2I=HL`>&J*ZT>qK@PEbox3Bqs=K}e!zFZGvxc)Zw|7!F2(~f)?YkNl>T>yYS87?iMfm-~NjhzOXyBwH6S5PMg51%Rxk1nk$}mi#_{T7&v7~; zOLdUqcb4h`MgREEJ|BkhwCyOath!l>T)_ObdMMv| z^I#T)O1#p2z;fcajNkKdOe?#8)PC0lnL)?lw7}u8I^VtCf`X~}mTnDHJ)ii8gXCj}-qL22EqcCX9(KhO-x1#K_T@tuE z+y>Nz%b;lc(95ELJPgLNzTA13A^~1OOSeI8d!g`Y#k(C--v{tzclPzy>BEzfHHg&@{z!!3_qhES>)C zrHv@qcr$L7MweZA!gpgR=A`O43KTQ@q{A(fd%$IsdAv+LXkh^-`nZ96#chBPpfWCx z$Q7#r@k=ii7IFKd*WD6v9N&*$)Nyx&XslowOPjyhiE42U9S<`Ep*3S-25IOW9d}O?Y|aP@M8YkM)OKG%Als(6BoowRQ zcYmThRVLI`zg8=>lI^X_bL7~kXR05VdLQ)Nk5dq0K(&F-hPR&)tzBb9JwGx40o2A!nhEdfwkyd z&9No6G=@X=JY()&yRkE;b?4kJs&Vz)VHDDd}C#tf;oJg4f#r;ylOMn7j^Yw zo!b-2Rj}sf#c|)$*!F058g2SY9u!d4Kf}iya9q09GBhpxy_XtCu`^V=;fVSTnxz!w zCjG_0ZreGv@Tl^yM|noU6r&0@7;e^(u3P4uHo>3$!#bZAe#JEk44IB1@CS&3jsWal zk7DxmvEKxzDZHFnAyf3jA!+Tz z0oytHJQD2?38r4nLe+8P0BHQej9U6oI_BIqdjPF3I00+QxS_;Uq;*Pujv&xJ6Bm}y zX_`Zt*^=I@mTlW1i#riQbg;$fT7gY64NjGEEP5RS&HMR>t@Los3+E&-FVEhwm#&VN`9uAwTxVR=(Nh9leCE7~vDulYq(5 zNf;hY^oQ1L?k~Ie@8hg99?_5v8_Bc(kWc)|%WR;?Ot%#FRE3^rqe&QyK&9=-UQ+i5 zY;|ScMZw$VD8#m}ERK?}^}6?`b)EzA6YSAOqsLLfk+Va&0FxWK!2MnE8yV$qVraRd z_aB(4nG>Z8PmOsB17_31j6n;UiNEc!2HDC7c!uWuiIo*)5DRXM*r-M z{;DQvEqf@KAokG5z4FNE`LQb`K-Yw1q~{v_DRI{?#-v_V9`~oL7pE^fuh~WS`4WBT z5XeegETUM3J@rsaLyRsD;+$7G8woFNGv8xFjlLbqh=mc3ZJs8f$)qoRKhlt^qQMx7 z){GfMfPVQ5y}gk+NI6FAEsRGnHw{YtAyAPAOppjy(DBTwTIXVH0909uZLle~>_*Mf z!wNg!CSL&P+Uaq+++>O((qaZkmqVgsU^~d5<%?@YVKjcm<4W9Q;hUKsv9r>8oV49513`eudbra1Km-$5&Yb0e^?%x6s;#ZpF7`h4t2r5_Y}Qj@mYTL?Yxx zzS_r15YF8UZ?Xe~v!$-O=v!4%uS1jxARI% z^31VnVr?mN1EBW|XtW!Rn$Q}Z5o}P!Xya22<41rhpm=npi1?zWRWR%8=4Ui&+042^ z@3_(lAu){Wkn;_QpRtP?N8r5hsd#-e>?d3EFSa$YUC1rEo1BE&>Wh~VX}|Jfma_zemcReve=!hhpTf`^|Hg4X*d}bL_szyHV2a^ay616 zEv*XRz`dg5Bi!i7=#3a>C6#grSOl%mR~=hx29$+SanP0P59?UzgWlQ|_Z84}mAsvw zhZwfyw+aZ&)!ER+3W+ z6~P_|+mJCnhVV4+|a%1>0u|HB+JdoUNo5<%KZ8d{u(NV}hb=HkjM3$XAS;=2u;Zw>j zMsL7tnxvW$0Uw?%lsAQU5V4cR3zbOb3f$1ZNyShx#%;XKgZ34K=ecP2hi}L%fn=BYa84>ZPs~ z1hVK*Kx8em7MkDzhTVWZ3Eh9IY2{DufGGk_*Z^|(l23uZQ=zB#@U6Ys`l^j z@SP|P0neuI&ntCF8D2C}LY`qM+A5cm!XVr4D_hR!)2u(d|ElyT@>6J;TT%M+wr;jW z@wKwFk}H;uS_T@2k{qO6T{|m7W$n3$Neg8R`aw zPOKaU!&)RkAFy=WnV)bIlG__caH;eUV!J$HDvnp)40@=NL1qNkjBcL0@EVMzP0l^} zkKKMF7gCteAHP!+tX4a2@W%~{dVP%4z}#xwF7JVR`NW*Pwsj^hea-_%dsh>zz&EW{xy z$H96t?&uK<%mG?YD3tm=d7B=Rap5;X{PqZnh-T(I(!$g4{x)Oc<0@x3LP~S-xcFC4 zK&LEhi2uuVPMozt`jHHUs!9#GeZIETy3N0f{5Glm95=k2tR(GrM$`s)I`AEr_{sE* zjS+*MA9<%N+Xo+nicuU6-e7M}7$#~h;(6)ylnEJzOE_6UnW>8wV|J1$WaQxR?eH<_ zI!pG33lOxWbx>Q~59JCQ zSJ}da|Dm%^(fkgMV5a*5zj5Hwg=U=7J}|*&wAK zD|g6NnBKeU#mZ`h4#W$nIfSkAeB%&31I|4z`>=YdDAFwjp|X8XE?vUhnA41wSXgyC zOAH?iowe;K?jQ8e|CHJay?xm@GL#aa82sf`%dPs*6+V5by$gSH;sQev#5J_mM1^-q+tkMhV#sg$X`4mx9CEC zbH#gct&fi)f_*Ig4admWOadlfUdu9dlH9zNb}HJbmE_oAmgxGlcg(lx_o8JNP%#n> zr~D_0uMOT~*(0XFE8bMQ3FV^ajU1fiAQ9tSis$_31iGZ;s!!kOMh#YAG^cFb4R6qR zqM<00`NdU8j&L40KSzPQ#4;S1{8PdmVhia;W4;|+T2J`6E6#b*!F(o~zLctUV8;r- z_@<=qLK5!7C4boWCsfZQo=ltK?XbddJqWxGi5NVA3^j@*rd){u z%Rd4K)mVBtV?%8)N`|(Fg+g8krq5P+5=M#RA69IDpC`A>v39Vno7IMuu#c$m5Hb6~J!{6|(7v5;hU|#- zTux)^&;-XF<$FJ-%rBnTW3QSP6?_?n4c`#2y*iz~#_AktD18>P&aE3zMB|Ak-R{`K zA=VJ_o<$6FLlxNC&QFNO9VdYLUTBi*)JZPXqPOk#4!J}YYOYm8Rb!E}n6mk7BA8u@ z4KZLplHv69TC|AkeW=u`nnfypj)=DN(@JccF)^WS)eM9vzm| z-!L*#T?xR*s(Y%qkqU=Lty?U{4c?M^k7_>icKr!W!^*=Yef%dS%vhDBA}CvzqLyU= zP>)Ff)QFt-jq2vZ4yFu0#GI^Zxd*67zIv z`SR^kSvo6TMHmSqoZ+$V`y%AT@g&VUXT^nLGH){?iuKi&7^8+pA?K#mHj%M;40gVN ztCqREhTI%;_dQo1l}fCTL(d-Hkie2gyG=g}Kz1zJyUPSwv~vEW!FqtS3p>vwqgRAv zSE?y9lS?UIrCE#K8ZB$y>5WeG_Dgq>r&Tjwe+-1?yF(_tQ5{34Y2DI^zRv~AHVURR z$N?Gow!-DufO!(&jVMl80u9Yr`b@t6C*=WDC628uP=u9zRC)vK>8-`3%cE0qVrb<2B!pR9j*OEF+$RJ51UYHtph!s!J%b1i$ zPv=RN9PI_g%938*L5!11_y;KNm1f$fC{N}X3C}t-_EYQgsS6k*bQ7Qi)f+Tiv89CO z`vkxU$6@+U--GpnQG;Y{Ed5sUb*K36`hCYI&|I>7AmW#;xcoZ;LIkd*;2!U-653Q= zMf`?=9p=ICwsOr7bT$4Ssl8sx^eAR|#m?Bg^j!U8t*2DG}o^)Q<{pPn1sH$^{H zF1H|}ow&^RtWu=jpE86MUqv-HkBBKUN2|n#Ld1ev%EMBpnGg^nn??f0!E+7f0H>wk z!F=a-vPh$Cf#PR7T3c3D5ojMC?;}Ef3$io65}vd(m+pZ4{yy zl8s;6tbe~=_3aV`rrxPEsN}npI+B6oVU{wugB1@CyCppIH%ExyaL*pU-((85N+0+OS=+@D6=BHcO094_{83Zw>O-$&1Xl(Y==(t3pc+>-5As0 zB2dk#yT5e5$=ewE;_9uo1x-2{_Z89e(c8qW#u5E6cNT&6|S}y z4u~Z_1)u()%rBO{d;OmTT*?>w1}KECzh<86JYEu zcNc0W%-X%*8RXUclDs?LKv5K$=e_t5bARQXM5gcOXs)$^BrgEpvxA@KP8XLX{Hj@U z_@9aoI!mxo4puHX3WtT&kw{<2LiPRYgy2vLbYq$!Sl44yq}#@TN$0r?+6bQbI=WT5 zY@+o@W(nE^Cn}^N;%cdV&?j~y}GVSy&j@rU3Ws{Y7?*MGtm(th%dwYS`*a_?GR z$A9sTftMR2W22#Yjtd88&Pjw2lf5$mE_Bh&;DIz1Ln|i3UK`S|mZ)QZ+(oqJh@%Y>b}4xg2XWbb0o6ju!w z-*o$g{>R`T**)%$klIi0y{;cI8@}Ga_x6;N=T)0mLg}k#kCf#S+`q{iGOI5f_KpHJ_C(HieSXx+8-Cne7wpL>({G=fe9RfV zr!Kj6Xo``{$|~F_1%fV>%-nCN4>2#TwFiS0fK*3KcUEMEGn@@`dK7(V_x6%Slp)qs zUQvg9!5XqBcfm}I?Fzb2-ll2Xk4FK z?$K*FQFs{W7SX>S>8tXv$Z-sA@Q2lMtXxFu5nJP^>~&e>4*A0I4f=X{(1AIc0Tjmh z`|9n1kyR7sf5h$VF@7IN zGKSOCik^1w&UEUXRyYgL)F=A1;6{&(JGMfbdT*-}_uigLK2WZAFzfG zpvyTDRW_l&Yr+h;p*qqdj~f#DG!sk``;}I99Z9&Am!^C$RYehlb<((MuS-@L(M2w@ z4IL333Z-b8Xst;lwDPNe9fw$LVcDZTyK4a>rx-(K#JuV@K?a zvcT}Rd$r>5YB)=@`YR_J_1#~$tks_EQ;Q;L%?2owYMz1DI&WV);aKNDSxR9s3oi)< zisvi0>h+xlt6Yh}Nz2RV@5m0WC=GMR)DL;PKg9xKwXaMPXyBfctZ1)ezO_fSsKzP( zN^+fo=Aysbk+vZPeW(B~JXUN6@Mn2_m6&wKbD_opa@!VlJ0VVaIemm7DOCXyx>Ai* zsp%^GL9O9Uef8LlpO@_zug{;BqJ{hQ1pf{(FRp>a{|@Ea{!-W1IS(M?$h~|9;pER< z5KUA}aMVe~*?;5M!+;Y(Pc6-E!O~{MVK|e(tgLBagN9S@7ky~h*7bYMgRplwN9Nct zVyK24h=cPa;OE|t<~Ml;8Uq6HEzfe`)BlGL!a9}9p@(}|gx@t0bG#nfXW zjQlXlHA7*xh0IR{crnMA`}c9@sV3tx-Z28v*E28GIhAt8h7xNg2I6@`lJ1# zzBNeMPM#Kyj2ffvZPqkdi>_tF2KM3A^E$lgmtr-A@jDo870}ImB{X&_m%}#Wc!FC3 zNZ=hc*RgvR;di0%p;7GkM;+;DkwQ&)>)GS`+y%}p3I~%{mgw6;ghFjkTX(QVnK09y zRFTS-?)IK{~gkNW*NpeTl;4A3i7bc zqiaicI{ZR#nF!v-JsY^crLRYjLHd+eWJipCy5PwJruzHLY_AHJ6Q{v@G%!W;Y~i61 zVM-WH)i7%eoXL3p=onD%#J%|*b{S9in`M`Y)m8Jc;?#*(oc=!bkn3)yQde_uxnaau z0;5GD++lmnK0YLko}AW@;|6!J#N`u!zV(9O2Y0ezWW#HWL{v?(J{hr|r+W}mX@&h< zkmf+?7iJW?v@U^)5DZ9{EQmpH{PE!hf892j%sLY~;p5%RPnjE8zK#|pdB5>Oqe`9U z1;!Lu-k0;^#TR9*)u?6*qHn_cEz1+F7ndQ29_SArvOv^qpM+8ptHMXFUo3V_R4Z0Y z+8=Z9l_wblY*{bahX;B57Ri~#IvD@pmTj)AvE~gN*O=J$%uZ4}7)~O)J}~H2^r#|} z3$um==69CAdfh8q1{R-6i0h*h5ieLv-2GjMOu_qzAv(|?x|u?N0x3Vr@IWA@XI>Onr1bzxI&KSPvQ(Iowd z*3^yl=7xiFa*pM9M`P~;ON-u+Qg6SHxJ?1%M553&UPWb8OtN4*4Y070ubfbH#q@<` z{G;@q{q>kJR*_}}MM|IHKKeJ9w1R800BHfvD`uZuKOjiGs<$v#tH1Z-^hHCi=cJvB zRmQ=%U0AxasQi|79Na;PH5Dcy)Ia#iEZ_Us8?ohA5ncsTkO9d2E>Es?)2m4pcW$h8 zc-Owr@tKZh04?|hWyZk={VK`bM--5EV zm9oz83s;45jr75S*rJ;10IYP}#>u+6XXho(2e1?smSI{UX6(diEzgMdKQp|ha}^0* zaJVgQeproc=t7*Yy|)KM%@&X(54)6_tBeSMemUUpWTu_$uV>(YAaE?jKJ&_SXr?QkJ-Ws9l=ySn~m~BUAp?aKKr0ZbTz%MbG=QFXRdEGHkjL#@2H2f>nczjo=ZC)5loow`${qoo;~wo0D6)zs zSl2!LJO(JjSsks$OlxF3{YLqE$XHcN*9N`HLHjqf(Z;|Cm&ZzH+O8k8nLLY8Bed6u z?x}}Tp7Yf0dsCgw$Y$72t6pfSw0lvu_hcuz@=H|tp(wW~5A}xxAtXbF3_#U$CXa?| zhjTO`$QM|Z=>W?=3D6C&)-0s!blk29Oe=DlgxV&&UwQj6&m$)iDHm~NXeF85vwS+| z4u%ceS9I-Q@k|k?oNP6HR=9eq4ClC|GK-n@{V57~xzLI~sMUgzLooj%Rq<_2=}^6v zs4aFzGGoIR1Wi(YhwhJJM-#hUx7Vs8&|UXakgg2i>Y2H=;mjMr=;s~L_g&*AD?=~R zo^uQv5s1&uNo3};H1T|;c*DQuH+&v{j|&ET4zi=CJy^5(`_md6rX*c0X&m%_0BRSx A4gdfE literal 0 HcmV?d00001 diff --git a/docs/_templates/sidebarintro.html b/docs/_templates/sidebarintro.html new file mode 100644 index 00000000..f65d1c75 --- /dev/null +++ b/docs/_templates/sidebarintro.html @@ -0,0 +1,17 @@ +

          About

          +

          + Flask-Security is an opinionated Flask extension which adds basic + security and authentication features to your Flask apps quickly + and easily. Flask-Social can also be used to add "social" or OAuth + login and connection management. +

          +

          Useful Links

          + + \ No newline at end of file diff --git a/docs/_templates/sidebarlogo.html b/docs/_templates/sidebarlogo.html new file mode 100644 index 00000000..2686677a --- /dev/null +++ b/docs/_templates/sidebarlogo.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/docs/api.rst b/docs/api.rst index ce4d0d8e..efad2ef9 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -50,27 +50,6 @@ Datastores :inherited-members: -Exceptions ----------- -.. autoexception:: flask_security.exceptions.BadCredentialsError - -.. autoexception:: flask_security.exceptions.AuthenticationError - -.. autoexception:: flask_security.exceptions.UserNotFoundError - -.. autoexception:: flask_security.exceptions.RoleNotFoundError - -.. autoexception:: flask_security.exceptions.UserDatastoreError - -.. autoexception:: flask_security.exceptions.UserCreationError - -.. autoexception:: flask_security.exceptions.RoleCreationError - -.. autoexception:: flask_security.exceptions.ConfirmationError - -.. autoexception:: flask_security.exceptions.ResetPasswordError - - Signals ------- See the documentation for the signals provided by the Flask-Login and diff --git a/docs/conf.py b/docs/conf.py index 64f49a5f..44323608 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -100,6 +100,8 @@ html_theme_options = { #'github_fork': 'mattupstate/flask-security', #'index_logo': False + 'touch_icon': 'touch-icon.png', + 'index_logo': 'logo-full.png' } # Add any paths that contain custom themes here, relative to this directory. @@ -135,7 +137,11 @@ #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +html_sidebars = { + 'index': ['sidebarintro.html', 'sourcelink.html', 'searchbox.html'], + '**': ['sidebarlogo.html', 'localtoc.html', 'relations.html', + 'sourcelink.html', 'searchbox.html'] +} # Additional templates that should be rendered to pages, maps page names to # template names. diff --git a/docs/contents.rst.inc b/docs/contents.rst.inc index 45ff59eb..c905e06a 100644 --- a/docs/contents.rst.inc +++ b/docs/contents.rst.inc @@ -4,7 +4,6 @@ Contents .. toctree:: :maxdepth: 1 - overview features configuration quickstart diff --git a/docs/index.rst b/docs/index.rst index 32784f31..97713c10 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,36 @@ Flask-Security ============== -Flask-Security quickly adds security features to your Flask application. +Flask-Security allows you to quickly add common security mechanisms to your +Flask application. They include: + +1. Session based authentication +2. Role management +3. Password encryption +4. Basic HTTP authentication +5. Token based authentication +6. Token based account activation (optional) +7. Token based password recovery/resetting (optional) +8. User registration (optional) +9. Login tracking (optional) + +Many of these features are made possible by integrating various Flask extensions +and libraries. They include: + +1. `Flask-Login `_ +2. `Flask-Mail `_ +3. `Flask-Principal `_ +4. `Flask-Script `_ +5. `Flask-WTF `_ +6. `itsdangerous `_ +7. `passlib `_ + +Additionally, it assumes you'll be using a common library for your database +connections and model definitions. Flask-Security supports the following Flask +extensions out of the box for data persistance: + +1. `Flask-SQLAlchemy `_ +2. `Flask-MongoEngine `_ .. include:: contents.rst.inc \ No newline at end of file diff --git a/docs/overview.rst b/docs/overview.rst deleted file mode 100644 index 571a9afe..00000000 --- a/docs/overview.rst +++ /dev/null @@ -1,33 +0,0 @@ -Overview -======== - -Flask-Security allows you to quickly add common security mechanisms to your -Flask application. They include: - -1. Session based authentication -2. Role management -3. Password encryption -4. Basic HTTP authentication -5. Token based authentication -6. Token based account activation (optional) -7. Token based password recovery/resetting (optional) -8. User registration (optional) -9. Login tracking (optional) - -Many of these features are made possible by integrating various Flask extensions -and libraries. They include: - -1. `Flask-Login `_ -2. `Flask-Mail `_ -3. `Flask-Principal `_ -4. `Flask-Script `_ -5. `Flask-WTF `_ -6. `itsdangerous `_ -7. `passlib `_ - -Additionally, it assumes you'll be using a common library for your database -connections and model definitions. Flask-Security supports the following Flask -extensions out of the box for data persistance: - -1. `Flask-SQLAlchemy `_ -2. `Flask-MongoEngine `_ \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 736bfed3..4a539496 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,4 +3,4 @@ source-dir = docs/ build-dir = docs/_build [upload_sphinx] -upload-dir = docs/_build/html \ No newline at end of file +upload-dir = docs/_build \ No newline at end of file diff --git a/setup.py b/setup.py index 45fa1700..6c423a42 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ install_requires=[ 'Flask>=0.9', 'Flask-Login>=0.1.3', - 'Flask-Mail>=0.6.1', + 'Flask-Mail>=0.7.0', 'Flask-Principal>=0.3', 'Flask-WTF>=0.5.4', 'itsdangerous>=0.15', @@ -47,7 +47,8 @@ 'nose', 'Flask-SQLAlchemy', 'Flask-MongoEngine', - 'py-bcrypt' + 'py-bcrypt', + 'simplejson' ], classifiers=[ 'Development Status :: 4 - Beta', diff --git a/tests/configured_tests.py b/tests/configured_tests.py new file mode 100644 index 00000000..0ac9d6f4 --- /dev/null +++ b/tests/configured_tests.py @@ -0,0 +1,463 @@ +from __future__ import with_statement + +import base64 +import time +import simplejson as json + +from flask.ext.security.utils import capture_registrations, \ + capture_reset_password_requests, capture_passwordless_login_requests + +from tests import SecurityTest + + +class ConfiguredPasswordHashSecurityTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_PASSWORD_HASH': 'bcrypt', + 'SECURITY_PASSWORD_SALT': 'so-salty', + 'USER_COUNT': 1 + } + + def test_authenticate(self): + r = self.authenticate(endpoint="/login") + self.assertIn('Home Page', r.data) + + +class ConfiguredSecurityTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_REGISTERABLE': True, + 'SECURITY_LOGOUT_URL': '/custom_logout', + 'SECURITY_LOGIN_URL': '/custom_login', + 'SECURITY_POST_LOGIN_VIEW': '/post_login', + 'SECURITY_POST_LOGOUT_VIEW': '/post_logout', + 'SECURITY_POST_REGISTER_VIEW': '/post_register', + 'SECURITY_UNAUTHORIZED_VIEW': '/unauthorized', + 'SECURITY_DEFAULT_HTTP_AUTH_REALM': 'Custom Realm' + } + + def test_login_view(self): + r = self._get('/custom_login') + self.assertIn("

          Login

          ", r.data) + + def test_authenticate(self): + r = self.authenticate(endpoint="/custom_login") + self.assertIn('Post Login', r.data) + + def test_logout(self): + self.authenticate(endpoint="/custom_login") + r = self.logout(endpoint="/custom_logout") + self.assertIn('Post Logout', r.data) + + def test_register_view(self): + r = self._get('/register') + self.assertIn('

          Register

          ', r.data) + + def test_register(self): + data = dict(email='dude@lp.com', + password='password', + password_confirm='password') + + r = self._post('/register', data=data, follow_redirects=True) + self.assertIn('Post Register', r.data) + + def test_register_json(self): + r = self._post('/register', + data='{ "email": "dude@lp.com", "password": "password" }', + content_type='application/json') + data = json.loads(r.data) + self.assertEquals(data['meta']['code'], 200) + self.assertIn('authentication_token', data['response']['user']) + + def test_register_existing_email(self): + data = dict(email='matt@lp.com', + password='password', + password_confirm='password') + r = self._post('/register', data=data, follow_redirects=True) + self.assertIn('matt@lp.com is already associated with an account', r.data) + + def test_unauthorized(self): + self.authenticate("joe@lp.com", endpoint="/custom_auth") + r = self._get("/admin", follow_redirects=True) + msg = 'You are not allowed to access the requested resouce' + self.assertIn(msg, r.data) + + def test_default_http_auth_realm(self): + r = self._get('/http', headers={ + 'Authorization': 'Basic ' + base64.b64encode("joe@lp.com:bogus") + }) + self.assertIn('

          Unauthorized

          ', r.data) + self.assertIn('WWW-Authenticate', r.headers) + self.assertEquals('Basic realm="Custom Realm"', + r.headers['WWW-Authenticate']) + + +class BadConfiguredSecurityTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_PASSWORD_HASH': 'bcrypt', + 'USER_COUNT': 1 + } + + def test_bad_configuration_raises_runtimer_error(self): + self.assertRaises(RuntimeError, self.authenticate) + + +class RegisterableTests(SecurityTest): + AUTH_CONFIG = { + 'SECURITY_REGISTERABLE': True, + 'USER_COUNT': 1 + } + + def test_register_valid_user(self): + data = dict(email='dude@lp.com', + password='password', + password_confirm='password') + self.client.post('/register', data=data, follow_redirects=True) + r = self.authenticate('dude@lp.com') + self.assertIn('Hello dude@lp.com', r.data) + + +class ConfirmableTests(SecurityTest): + AUTH_CONFIG = { + 'SECURITY_CONFIRMABLE': True, + 'SECURITY_REGISTERABLE': True, + 'USER_COUNT': 1 + } + + def test_login_before_confirmation(self): + e = 'dude@lp.com' + self.register(e) + r = self.authenticate(email=e) + self.assertIn(self.get_message('CONFIRMATION_REQUIRED'), r.data) + + def test_send_confirmation_of_already_confirmed_account(self): + e = 'dude@lp.com' + + with capture_registrations() as registrations: + self.register(e) + token = registrations[0]['confirm_token'] + + self.client.get('/confirm/' + token, follow_redirects=True) + self.logout() + r = self.client.post('/confirm', data=dict(email=e)) + self.assertIn(self.get_message('ALREADY_CONFIRMED'), r.data) + + def test_register_sends_confirmation_email(self): + e = 'dude@lp.com' + with self.app.extensions['mail'].record_messages() as outbox: + self.register(e) + self.assertEqual(len(outbox), 1) + self.assertIn(e, outbox[0].html) + + def test_confirm_email(self): + e = 'dude@lp.com' + + with capture_registrations() as registrations: + self.register(e) + token = registrations[0]['confirm_token'] + + r = self.client.get('/confirm/' + token, follow_redirects=True) + + msg = self.app.config['SECURITY_MSG_EMAIL_CONFIRMED'][0] + self.assertIn(msg, r.data) + + def test_invalid_token_when_confirming_email(self): + r = self.client.get('/confirm/bogus', follow_redirects=True) + self.assertIn('Invalid confirmation token', r.data) + + def test_send_confirmation_with_invalid_email(self): + r = self._post('/confirm', data=dict(email='bogus@bogus.com')) + self.assertIn('Specified user does not exist', r.data) + + def test_resend_confirmation(self): + e = 'dude@lp.com' + self.register(e) + r = self._post('/confirm', data={'email': e}) + + msg = self.get_message('CONFIRMATION_REQUEST', email=e) + self.assertIn(msg, r.data) + + +class ExpiredConfirmationTest(SecurityTest): + AUTH_CONFIG = { + 'SECURITY_CONFIRMABLE': True, + 'SECURITY_REGISTERABLE': True, + 'SECURITY_CONFIRM_EMAIL_WITHIN': '1 milliseconds', + 'USER_COUNT': 1 + } + + def test_expired_confirmation_token_sends_email(self): + e = 'dude@lp.com' + + with capture_registrations() as registrations: + self.register(e) + token = registrations[0]['confirm_token'] + + time.sleep(1.25) + + with self.app.extensions['mail'].record_messages() as outbox: + r = self.client.get('/confirm/' + token, follow_redirects=True) + + self.assertEqual(len(outbox), 1) + self.assertNotIn(token, outbox[0].html) + + expire_text = self.AUTH_CONFIG['SECURITY_CONFIRM_EMAIL_WITHIN'] + msg = self.app.config['SECURITY_MSG_CONFIRMATION_EXPIRED'][0] + msg = msg % dict(within=expire_text, email=e) + self.assertIn(msg, r.data) + + +class LoginWithoutImmediateConfirmTests(SecurityTest): + AUTH_CONFIG = { + 'SECURITY_CONFIRMABLE': True, + 'SECURITY_REGISTERABLE': True, + 'SECURITY_LOGIN_WITHOUT_CONFIRMATION': True, + 'USER_COUNT': 1 + } + + def test_register_valid_user_automatically_signs_in(self): + e = 'dude@lp.com' + p = 'password' + data = dict(email=e, password=p, password_confirm=p) + r = self.client.post('/register', data=data, follow_redirects=True) + self.assertIn(e, r.data) + + +class RecoverableTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_RECOVERABLE': True, + 'SECURITY_RESET_PASSWORD_ERROR_VIEW': '/', + 'SECURITY_POST_FORGOT_VIEW': '/' + } + + def test_reset_view(self): + with capture_reset_password_requests() as requests: + r = self.client.post('/reset', + data=dict(email='joe@lp.com'), + follow_redirects=True) + t = requests[0]['token'] + r = self._get('/reset/' + t) + self.assertIn('

          Reset password

          ', r.data) + + def test_forgot_post_sends_email(self): + with capture_reset_password_requests(): + with self.app.extensions['mail'].record_messages() as outbox: + self.client.post('/reset', data=dict(email='joe@lp.com')) + self.assertEqual(len(outbox), 1) + + def test_forgot_password_invalid_email(self): + r = self.client.post('/reset', + data=dict(email='larry@lp.com'), + follow_redirects=True) + self.assertIn("Specified user does not exist", r.data) + + def test_reset_password_with_valid_token(self): + with capture_reset_password_requests() as requests: + r = self.client.post('/reset', + data=dict(email='joe@lp.com'), + follow_redirects=True) + t = requests[0]['token'] + + r = self._post('/reset/' + t, data={ + 'password': 'newpassword', + 'password_confirm': 'newpassword' + }, follow_redirects=True) + + r = self.logout() + r = self.authenticate('joe@lp.com', 'newpassword') + self.assertIn('Hello joe@lp.com', r.data) + + def test_reset_password_with_invalid_token(self): + r = self._post('/reset/bogus', data={ + 'password': 'newpassword', + 'password_confirm': 'newpassword' + }, follow_redirects=True) + + self.assertIn(self.get_message('INVALID_RESET_PASSWORD_TOKEN'), r.data) + + +class ExpiredResetPasswordTest(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_RECOVERABLE': True, + 'SECURITY_RESET_PASSWORD_WITHIN': '1 milliseconds' + } + + def test_reset_password_with_expired_token(self): + with capture_reset_password_requests() as requests: + r = self.client.post('/reset', + data=dict(email='joe@lp.com'), + follow_redirects=True) + t = requests[0]['token'] + + time.sleep(1) + + r = self.client.post('/reset/' + t, data={ + 'password': 'newpassword', + 'password_confirm': 'newpassword' + }, follow_redirects=True) + + self.assertIn('You did not reset your password within', r.data) + + +class TrackableTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_TRACKABLE': True, + 'USER_COUNT': 1 + } + + def test_did_track(self): + e = 'matt@lp.com' + self.authenticate(email=e) + self.logout() + self.authenticate(email=e) + + with self.app.test_request_context('/profile'): + user = self.app.security.datastore.find_user(email=e) + self.assertIsNotNone(user.last_login_at) + self.assertIsNotNone(user.current_login_at) + self.assertEquals('untrackable', user.last_login_ip) + self.assertEquals('untrackable', user.current_login_ip) + self.assertEquals(2, user.login_count) + + +class PasswordlessTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_PASSWORDLESS': True + } + + def test_login_request_for_inactive_user(self): + msg = self.app.config['SECURITY_MSG_DISABLED_ACCOUNT'][0] + r = self.client.post('/login', + data=dict(email='tiya@lp.com'), + follow_redirects=True) + self.assertIn(msg, r.data) + + def test_request_login_token_sends_email_and_can_login(self): + e = 'matt@lp.com' + r, user, token = None, None, None + + with capture_passwordless_login_requests() as requests: + with self.app.extensions['mail'].record_messages() as outbox: + r = self.client.post('/login', + data=dict(email=e), + follow_redirects=True) + + self.assertEqual(len(outbox), 1) + + self.assertEquals(1, len(requests)) + self.assertIn('user', requests[0]) + self.assertIn('login_token', requests[0]) + + user = requests[0]['user'] + token = requests[0]['login_token'] + + msg = self.app.config['SECURITY_MSG_LOGIN_EMAIL_SENT'][0] + msg = msg % dict(email=user.email) + self.assertIn(msg, r.data) + + r = self.client.get('/login/' + token, follow_redirects=True) + msg = self.get_message('PASSWORDLESS_LOGIN_SUCCESSFUL') + self.assertIn(msg, r.data) + + r = self.client.get('/profile') + self.assertIn('Profile Page', r.data) + + def test_invalid_login_token(self): + msg = self.app.config['SECURITY_MSG_INVALID_LOGIN_TOKEN'][0] + r = self._get('/login/bogus', follow_redirects=True) + self.assertIn(msg, r.data) + + def test_token_login_when_already_authenticated(self): + with capture_passwordless_login_requests() as requests: + self.client.post('/login', + data=dict(email='matt@lp.com'), + follow_redirects=True) + token = requests[0]['login_token'] + + r = self.client.get('/login/' + token, follow_redirects=True) + msg = self.get_message('PASSWORDLESS_LOGIN_SUCCESSFUL') + self.assertIn(msg, r.data) + + r = self.client.get('/login/' + token, follow_redirects=True) + msg = self.get_message('PASSWORDLESS_LOGIN_SUCCESSFUL') + self.assertNotIn(msg, r.data) + + def test_send_login_with_invalid_email(self): + r = self._post('/login', data=dict(email='bogus@bogus.com')) + self.assertIn('Specified user does not exist', r.data) + + +class ExpiredLoginTokenTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_PASSWORDLESS': True, + 'SECURITY_LOGIN_WITHIN': '1 milliseconds', + 'USER_COUNT': 1 + } + + def test_expired_login_token_sends_email(self): + e = 'matt@lp.com' + + with capture_passwordless_login_requests() as requests: + self.client.post('/login', + data=dict(email=e), + follow_redirects=True) + token = requests[0]['login_token'] + + time.sleep(1.25) + + with self.app.extensions['mail'].record_messages() as outbox: + r = self.client.get('/login/' + token, follow_redirects=True) + + expire_text = self.AUTH_CONFIG['SECURITY_LOGIN_WITHIN'] + msg = self.app.config['SECURITY_MSG_LOGIN_EXPIRED'][0] + msg = msg % dict(within=expire_text, email=e) + self.assertIn(msg, r.data) + + self.assertEqual(len(outbox), 1) + self.assertIn(e, outbox[0].html) + self.assertNotIn(token, outbox[0].html) + + +class AsyncMailTaskTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_RECOVERABLE': True, + 'USER_COUNT': 1 + } + + def setUp(self): + super(AsyncMailTaskTests, self).setUp() + self.mail_sent = False + + def test_send_email_task_is_called(self): + @self.app.security.send_mail_task + def send_email(msg): + self.mail_sent = True + + self.client.post('/reset', data=dict(email='matt@lp.com')) + self.assertTrue(self.mail_sent) + + +class NoBlueprintTests(SecurityTest): + + AUTH_CONFIG = { + 'USER_COUNT': 1 + } + + def _create_app(self, auth_config): + return super(NoBlueprintTests, self)._create_app(auth_config, False) + + def test_login_endpoint_is_404(self): + r = self._get('/login') + self.assertEqual(404, r.status_code) + + def test_http_auth_without_blueprint(self): + auth = 'Basic ' + base64.b64encode("matt@lp.com:password") + r = self._get('/http', headers={'Authorization': auth}) + self.assertIn('HTTP Authentication', r.data) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 8f2a0ba2..fa3bb05b 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -3,17 +3,9 @@ from __future__ import with_statement import base64 -import time - +import simplejson as json from cookielib import Cookie -try: - import simplejson as json -except ImportError: - import json - -from flask.ext.security.utils import capture_registrations, \ - capture_reset_password_requests, capture_passwordless_login_requests from werkzeug.utils import parse_cookie from tests import SecurityTest @@ -197,401 +189,6 @@ def test_token_loader_does_not_fail_with_invalid_token(self): self.assertNotIn('BadSignature', r.data) -class ConfiguredPasswordHashSecurityTests(SecurityTest): - - AUTH_CONFIG = { - 'SECURITY_PASSWORD_HASH': 'bcrypt', - 'SECURITY_PASSWORD_SALT': 'so-salty', - 'USER_COUNT': 1 - } - - def test_authenticate(self): - r = self.authenticate(endpoint="/login") - self.assertIn('Home Page', r.data) - - -class ConfiguredSecurityTests(SecurityTest): - - AUTH_CONFIG = { - 'SECURITY_REGISTERABLE': True, - 'SECURITY_LOGOUT_URL': '/custom_logout', - 'SECURITY_LOGIN_URL': '/custom_login', - 'SECURITY_POST_LOGIN_VIEW': '/post_login', - 'SECURITY_POST_LOGOUT_VIEW': '/post_logout', - 'SECURITY_POST_REGISTER_VIEW': '/post_register', - 'SECURITY_UNAUTHORIZED_VIEW': '/unauthorized', - 'SECURITY_DEFAULT_HTTP_AUTH_REALM': 'Custom Realm' - } - - def test_login_view(self): - r = self._get('/custom_login') - self.assertIn("

          Login

          ", r.data) - - def test_authenticate(self): - r = self.authenticate(endpoint="/custom_login") - self.assertIn('Post Login', r.data) - - def test_logout(self): - self.authenticate(endpoint="/custom_login") - r = self.logout(endpoint="/custom_logout") - self.assertIn('Post Logout', r.data) - - def test_register_view(self): - r = self._get('/register') - self.assertIn('

          Register

          ', r.data) - - def test_register(self): - data = dict(email='dude@lp.com', - password='password', - password_confirm='password') - - r = self._post('/register', data=data, follow_redirects=True) - self.assertIn('Post Register', r.data) - - def test_register_json(self): - r = self._post('/register', - data='{ "email": "dude@lp.com", "password": "password" }', - content_type='application/json') - data = json.loads(r.data) - self.assertEquals(data['meta']['code'], 200) - self.assertIn('authentication_token', data['response']['user']) - - def test_register_existing_email(self): - data = dict(email='matt@lp.com', - password='password', - password_confirm='password') - r = self._post('/register', data=data, follow_redirects=True) - self.assertIn('matt@lp.com is already associated with an account', r.data) - - def test_unauthorized(self): - self.authenticate("joe@lp.com", endpoint="/custom_auth") - r = self._get("/admin", follow_redirects=True) - msg = 'You are not allowed to access the requested resouce' - self.assertIn(msg, r.data) - - def test_default_http_auth_realm(self): - r = self._get('/http', headers={ - 'Authorization': 'Basic ' + base64.b64encode("joe@lp.com:bogus") - }) - self.assertIn('

          Unauthorized

          ', r.data) - self.assertIn('WWW-Authenticate', r.headers) - self.assertEquals('Basic realm="Custom Realm"', r.headers['WWW-Authenticate']) - - -class BadConfiguredSecurityTests(SecurityTest): - - AUTH_CONFIG = { - 'SECURITY_PASSWORD_HASH': 'bcrypt', - 'USER_COUNT': 1 - } - - def test_bad_configuration_raises_runtimer_error(self): - self.assertRaises(RuntimeError, self.authenticate) - - -class RegisterableTests(SecurityTest): - AUTH_CONFIG = { - 'SECURITY_REGISTERABLE': True, - 'USER_COUNT': 1 - } - - def test_register_valid_user(self): - data = dict(email='dude@lp.com', password='password', password_confirm='password') - self.client.post('/register', data=data, follow_redirects=True) - r = self.authenticate('dude@lp.com') - self.assertIn('Hello dude@lp.com', r.data) - - -class ConfirmableTests(SecurityTest): - AUTH_CONFIG = { - 'SECURITY_CONFIRMABLE': True, - 'SECURITY_REGISTERABLE': True, - 'USER_COUNT': 1 - } - - def test_login_before_confirmation(self): - e = 'dude@lp.com' - self.register(e) - r = self.authenticate(email=e) - self.assertIn(self.get_message('CONFIRMATION_REQUIRED'), r.data) - - def test_send_confirmation_of_already_confirmed_account(self): - e = 'dude@lp.com' - - with capture_registrations() as registrations: - self.register(e) - token = registrations[0]['confirm_token'] - - self.client.get('/confirm/' + token, follow_redirects=True) - self.logout() - r = self.client.post('/confirm', data=dict(email=e)) - self.assertIn(self.get_message('ALREADY_CONFIRMED'), r.data) - - def test_register_sends_confirmation_email(self): - e = 'dude@lp.com' - with self.app.extensions['mail'].record_messages() as outbox: - self.register(e) - self.assertEqual(len(outbox), 1) - self.assertIn(e, outbox[0].html) - - def test_confirm_email(self): - e = 'dude@lp.com' - - with capture_registrations() as registrations: - self.register(e) - token = registrations[0]['confirm_token'] - - r = self.client.get('/confirm/' + token, follow_redirects=True) - - msg = self.app.config['SECURITY_MSG_EMAIL_CONFIRMED'][0] - self.assertIn(msg, r.data) - - def test_invalid_token_when_confirming_email(self): - r = self.client.get('/confirm/bogus', follow_redirects=True) - self.assertIn('Invalid confirmation token', r.data) - - def test_send_confirmation_with_invalid_email(self): - r = self._post('/confirm', data=dict(email='bogus@bogus.com')) - self.assertIn('Specified user does not exist', r.data) - - def test_resend_confirmation(self): - e = 'dude@lp.com' - self.register(e) - r = self._post('/confirm', data={'email': e}) - self.assertIn(self.get_message('CONFIRMATION_REQUEST', email=e), r.data) - - -class ExpiredConfirmationTest(SecurityTest): - AUTH_CONFIG = { - 'SECURITY_CONFIRMABLE': True, - 'SECURITY_REGISTERABLE': True, - 'SECURITY_CONFIRM_EMAIL_WITHIN': '1 milliseconds', - 'USER_COUNT': 1 - } - - def test_expired_confirmation_token_sends_email(self): - e = 'dude@lp.com' - - with capture_registrations() as registrations: - self.register(e) - token = registrations[0]['confirm_token'] - - time.sleep(1.25) - - with self.app.extensions['mail'].record_messages() as outbox: - r = self.client.get('/confirm/' + token, follow_redirects=True) - - self.assertEqual(len(outbox), 1) - self.assertNotIn(token, outbox[0].html) - - expire_text = self.AUTH_CONFIG['SECURITY_CONFIRM_EMAIL_WITHIN'] - msg = self.app.config['SECURITY_MSG_CONFIRMATION_EXPIRED'][0] % dict(within=expire_text, email=e) - self.assertIn(msg, r.data) - - -class LoginWithoutImmediateConfirmTests(SecurityTest): - AUTH_CONFIG = { - 'SECURITY_CONFIRMABLE': True, - 'SECURITY_REGISTERABLE': True, - 'SECURITY_LOGIN_WITHOUT_CONFIRMATION': True, - 'USER_COUNT': 1 - } - - def test_register_valid_user_automatically_signs_in(self): - e = 'dude@lp.com' - p = 'password' - data = dict(email=e, password=p, password_confirm=p) - r = self.client.post('/register', data=data, follow_redirects=True) - self.assertIn(e, r.data) - - -class RecoverableTests(SecurityTest): - - AUTH_CONFIG = { - 'SECURITY_RECOVERABLE': True, - 'SECURITY_RESET_PASSWORD_ERROR_VIEW': '/', - 'SECURITY_POST_FORGOT_VIEW': '/' - } - - def test_reset_view(self): - with capture_reset_password_requests() as requests: - r = self.client.post('/reset', - data=dict(email='joe@lp.com'), - follow_redirects=True) - t = requests[0]['token'] - r = self._get('/reset/' + t) - self.assertIn('

          Reset password

          ', r.data) - - def test_forgot_post_sends_email(self): - with capture_reset_password_requests(): - with self.app.extensions['mail'].record_messages() as outbox: - self.client.post('/reset', data=dict(email='joe@lp.com')) - self.assertEqual(len(outbox), 1) - - def test_forgot_password_invalid_email(self): - r = self.client.post('/reset', - data=dict(email='larry@lp.com'), - follow_redirects=True) - self.assertIn("Specified user does not exist", r.data) - - def test_reset_password_with_valid_token(self): - with capture_reset_password_requests() as requests: - r = self.client.post('/reset', - data=dict(email='joe@lp.com'), - follow_redirects=True) - t = requests[0]['token'] - - r = self._post('/reset/' + t, data={ - 'password': 'newpassword', - 'password_confirm': 'newpassword' - }, follow_redirects=True) - - r = self.logout() - r = self.authenticate('joe@lp.com', 'newpassword') - self.assertIn('Hello joe@lp.com', r.data) - - def test_reset_password_with_invalid_token(self): - r = self._post('/reset/bogus', data={ - 'password': 'newpassword', - 'password_confirm': 'newpassword' - }, follow_redirects=True) - - self.assertIn(self.get_message('INVALID_RESET_PASSWORD_TOKEN'), r.data) - - -class ExpiredResetPasswordTest(SecurityTest): - - AUTH_CONFIG = { - 'SECURITY_RECOVERABLE': True, - 'SECURITY_RESET_PASSWORD_WITHIN': '1 milliseconds' - } - - def test_reset_password_with_expired_token(self): - with capture_reset_password_requests() as requests: - r = self.client.post('/reset', - data=dict(email='joe@lp.com'), - follow_redirects=True) - t = requests[0]['token'] - - time.sleep(1) - - r = self.client.post('/reset/' + t, data={ - 'password': 'newpassword', - 'password_confirm': 'newpassword' - }, follow_redirects=True) - - self.assertIn('You did not reset your password within', r.data) - - -class TrackableTests(SecurityTest): - - AUTH_CONFIG = { - 'SECURITY_TRACKABLE': True, - 'USER_COUNT': 1 - } - - def test_did_track(self): - e = 'matt@lp.com' - self.authenticate(email=e) - self.logout() - self.authenticate(email=e) - - with self.app.test_request_context('/profile'): - user = self.app.security.datastore.find_user(email=e) - self.assertIsNotNone(user.last_login_at) - self.assertIsNotNone(user.current_login_at) - self.assertEquals('untrackable', user.last_login_ip) - self.assertEquals('untrackable', user.current_login_ip) - self.assertEquals(2, user.login_count) - - -class PasswordlessTests(SecurityTest): - - AUTH_CONFIG = { - 'SECURITY_PASSWORDLESS': True - } - - def test_login_request_for_inactive_user(self): - msg = self.app.config['SECURITY_MSG_DISABLED_ACCOUNT'][0] - r = self.client.post('/login', data=dict(email='tiya@lp.com'), follow_redirects=True) - self.assertIn(msg, r.data) - - def test_request_login_token_sends_email_and_can_login(self): - e = 'matt@lp.com' - r, user, token = None, None, None - - with capture_passwordless_login_requests() as requests: - with self.app.extensions['mail'].record_messages() as outbox: - r = self.client.post('/login', data=dict(email=e), follow_redirects=True) - - self.assertEqual(len(outbox), 1) - - self.assertEquals(1, len(requests)) - self.assertIn('user', requests[0]) - self.assertIn('login_token', requests[0]) - - user = requests[0]['user'] - token = requests[0]['login_token'] - - msg = self.app.config['SECURITY_MSG_LOGIN_EMAIL_SENT'][0] % dict(email=user.email) - self.assertIn(msg, r.data) - - r = self.client.get('/login/' + token, follow_redirects=True) - self.assertIn(self.get_message('PASSWORDLESS_LOGIN_SUCCESSFUL'), r.data) - - r = self.client.get('/profile') - self.assertIn('Profile Page', r.data) - - def test_invalid_login_token(self): - msg = self.app.config['SECURITY_MSG_INVALID_LOGIN_TOKEN'][0] - r = self._get('/login/bogus', follow_redirects=True) - self.assertIn(msg, r.data) - - def test_token_login_forwards_to_post_login_view_when_already_authenticated(self): - with capture_passwordless_login_requests() as requests: - self.client.post('/login', data=dict(email='matt@lp.com'), follow_redirects=True) - token = requests[0]['login_token'] - - r = self.client.get('/login/' + token, follow_redirects=True) - self.assertIn(self.get_message('PASSWORDLESS_LOGIN_SUCCESSFUL'), r.data) - - r = self.client.get('/login/' + token, follow_redirects=True) - self.assertNotIn(self.get_message('PASSWORDLESS_LOGIN_SUCCESSFUL'), r.data) - - def test_send_login_with_invalid_email(self): - r = self._post('/login', data=dict(email='bogus@bogus.com')) - self.assertIn('Specified user does not exist', r.data) - - -class ExpiredLoginTokenTests(SecurityTest): - - AUTH_CONFIG = { - 'SECURITY_PASSWORDLESS': True, - 'SECURITY_LOGIN_WITHIN': '1 milliseconds', - 'USER_COUNT': 1 - } - - def test_expired_login_token_sends_email(self): - e = 'matt@lp.com' - - with capture_passwordless_login_requests() as requests: - self.client.post('/login', data=dict(email=e), follow_redirects=True) - token = requests[0]['login_token'] - - time.sleep(1.25) - - with self.app.extensions['mail'].record_messages() as outbox: - r = self.client.get('/login/' + token, follow_redirects=True) - - expire_text = self.AUTH_CONFIG['SECURITY_LOGIN_WITHIN'] - msg = self.app.config['SECURITY_MSG_LOGIN_EXPIRED'][0] % dict(within=expire_text, email=e) - self.assertIn(msg, r.data) - - self.assertEqual(len(outbox), 1) - self.assertIn(e, outbox[0].html) - self.assertNotIn(token, outbox[0].html) - - class MongoEngineSecurityTests(DefaultSecurityTests): def _create_app(self, auth_config): @@ -627,43 +224,3 @@ class MongoEngineDatastoreTests(DefaultDatastoreTests): def _create_app(self, auth_config): from tests.test_app.mongoengine import create_app return create_app(auth_config) - - -class AsyncMailTaskTests(SecurityTest): - - AUTH_CONFIG = { - 'SECURITY_RECOVERABLE': True, - 'USER_COUNT': 1 - } - - def setUp(self): - super(AsyncMailTaskTests, self).setUp() - self.mail_sent = False - - def test_send_email_task_is_called(self): - @self.app.security.send_mail_task - def send_email(msg): - self.mail_sent = True - - self.client.post('/reset', data=dict(email='matt@lp.com')) - self.assertTrue(self.mail_sent) - - -class NoBlueprintTests(SecurityTest): - - AUTH_CONFIG = { - 'USER_COUNT': 1 - } - - def _create_app(self, auth_config): - return super(NoBlueprintTests, self)._create_app(auth_config, False) - - def test_login_endpoint_is_404(self): - r = self._get('/login') - self.assertEqual(404, r.status_code) - - def test_http_auth_without_blueprint(self): - r = self._get('/http', headers={ - 'Authorization': 'Basic ' + base64.b64encode("matt@lp.com:password") - }) - self.assertIn('HTTP Authentication', r.data) From 0154cce46c43f54d98a54f29ed95ad38558c42a2 Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 11 Oct 2012 16:58:53 -0400 Subject: [PATCH 233/234] Only give out auth token on the login endpoint --- flask_security/views.py | 28 +++++++++++++++++----------- tests/configured_tests.py | 30 +++++++++++++++++++++++++----- tests/functional_tests.py | 18 ++++++++++++++---- 3 files changed, 56 insertions(+), 20 deletions(-) diff --git a/flask_security/views.py b/flask_security/views.py index 73f80714..89bb0dc3 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -35,7 +35,7 @@ _datastore = LocalProxy(lambda: _security.datastore) -def _render_json(form, include_auth_token=True): +def _render_json(form, include_auth_token=False): has_errors = len(form.errors) > 0 if has_errors: @@ -77,7 +77,7 @@ def login(): return redirect(get_post_login_redirect()) if request.json: - return _render_json(form) + return _render_json(form, True) return render_template('security/login_user.html', login_user_form=form, @@ -140,9 +140,11 @@ def send_login(): if form.validate_on_submit(): send_login_instructions(form.user) - if request.json: - return _render_json(form, False) - do_flash(*get_message('LOGIN_EMAIL_SENT', email=form.user.email)) + if request.json is None: + do_flash(*get_message('LOGIN_EMAIL_SENT', email=form.user.email)) + + if request.json: + return _render_json(form) return render_template('security/send_login.html', send_login_form=form, @@ -181,9 +183,11 @@ def send_confirmation(): if form.validate_on_submit(): send_confirmation_instructions(form.user) - if request.json: - return _render_json(form, False) - do_flash(*get_message('CONFIRMATION_REQUEST', email=form.user.email)) + if request.json is None: + do_flash(*get_message('CONFIRMATION_REQUEST', email=form.user.email)) + + if request.json: + return _render_json(form) return render_template('security/send_confirmation.html', send_confirmation_form=form, @@ -225,9 +229,11 @@ def forgot_password(): if form.validate_on_submit(): send_reset_password_instructions(form.user) - if request.json: - return _render_json(form, False) - do_flash(*get_message('PASSWORD_RESET_REQUEST', email=form.user.email)) + if request.json is None: + do_flash(*get_message('PASSWORD_RESET_REQUEST', email=form.user.email)) + + if request.json: + return _render_json(form) return render_template('security/forgot_password.html', forgot_password_form=form, diff --git a/tests/configured_tests.py b/tests/configured_tests.py index 0ac9d6f4..b2e75bc7 100644 --- a/tests/configured_tests.py +++ b/tests/configured_tests.py @@ -62,19 +62,18 @@ def test_register(self): self.assertIn('Post Register', r.data) def test_register_json(self): - r = self._post('/register', - data='{ "email": "dude@lp.com", "password": "password" }', - content_type='application/json') + data = '{ "email": "dude@lp.com", "password": "password" }' + r = self._post('/register', data=data, content_type='application/json') data = json.loads(r.data) self.assertEquals(data['meta']['code'], 200) - self.assertIn('authentication_token', data['response']['user']) def test_register_existing_email(self): data = dict(email='matt@lp.com', password='password', password_confirm='password') r = self._post('/register', data=data, follow_redirects=True) - self.assertIn('matt@lp.com is already associated with an account', r.data) + msg = 'matt@lp.com is already associated with an account' + self.assertIn(msg, r.data) def test_unauthorized(self): self.authenticate("joe@lp.com", endpoint="/custom_auth") @@ -166,6 +165,11 @@ def test_invalid_token_when_confirming_email(self): r = self.client.get('/confirm/bogus', follow_redirects=True) self.assertIn('Invalid confirmation token', r.data) + def test_send_confirmation_json(self): + r = self._post('/confirm', data='{"email": "matt@lp.com"}', + content_type='application/json') + self.assertEquals(r.status_code, 200) + def test_send_confirmation_with_invalid_email(self): r = self._post('/confirm', data=dict(email='bogus@bogus.com')) self.assertIn('Specified user does not exist', r.data) @@ -247,6 +251,11 @@ def test_forgot_post_sends_email(self): self.client.post('/reset', data=dict(email='joe@lp.com')) self.assertEqual(len(outbox), 1) + def test_forgot_password_json(self): + r = self.client.post('/reset', data='{"email": "matt@lp.com"}', + content_type="application/json") + self.assertEquals(r.status_code, 200) + def test_forgot_password_invalid_email(self): r = self.client.post('/reset', data=dict(email='larry@lp.com'), @@ -337,6 +346,17 @@ def test_login_request_for_inactive_user(self): follow_redirects=True) self.assertIn(msg, r.data) + def test_request_login_token_with_json_and_valid_email(self): + data = '{"email": "matt@lp.com", "password": "password"}' + r = self.client.post('/login', data=data, content_type='application/json') + self.assertEquals(r.status_code, 200) + self.assertNotIn('error', r.data) + + def test_request_login_token_with_json_and_invalid_email(self): + data = '{"email": "nobody@lp.com", "password": "password"}' + r = self.client.post('/login', data=data, content_type='application/json') + self.assertIn('errors', r.data) + def test_request_login_token_sends_email_and_can_login(self): e = 'matt@lp.com' r, user, token = None, None, None diff --git a/tests/functional_tests.py b/tests/functional_tests.py index fa3bb05b..c9fd8dab 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -148,7 +148,8 @@ def test_invalid_http_auth_invalid_username(self): }) self.assertIn('

          Unauthorized

          ', r.data) self.assertIn('WWW-Authenticate', r.headers) - self.assertEquals('Basic realm="Login Required"', r.headers['WWW-Authenticate']) + self.assertEquals('Basic realm="Login Required"', + r.headers['WWW-Authenticate']) def test_invalid_http_auth_bad_password(self): r = self._get('/http', headers={ @@ -156,7 +157,8 @@ def test_invalid_http_auth_bad_password(self): }) self.assertIn('

          Unauthorized

          ', r.data) self.assertIn('WWW-Authenticate', r.headers) - self.assertEquals('Basic realm="Login Required"', r.headers['WWW-Authenticate']) + self.assertEquals('Basic realm="Login Required"', + r.headers['WWW-Authenticate']) def test_custom_http_auth_realm(self): r = self._get('/http_custom_realm', headers={ @@ -164,7 +166,8 @@ def test_custom_http_auth_realm(self): }) self.assertIn('

          Unauthorized

          ', r.data) self.assertIn('WWW-Authenticate', r.headers) - self.assertEquals('Basic realm="My Realm"', r.headers['WWW-Authenticate']) + self.assertEquals('Basic realm="My Realm"', + r.headers['WWW-Authenticate']) def test_user_deleted_during_session_reverts_to_anonymous_user(self): self.authenticate() @@ -184,7 +187,14 @@ def test_remember_token(self): self.assertIn('profile', r.data) def test_token_loader_does_not_fail_with_invalid_token(self): - self.client.cookie_jar.set_cookie(Cookie(version=0, name='remember_token', value='None', port=None, port_specified=False, domain='www.example.com', domain_specified=False, domain_initial_dot=False, path='/', path_specified=True, secure=False, expires=None, discard=True, comment=None, comment_url=None, rest={'HttpOnly': None}, rfc2109=False)) + c = Cookie(version=0, name='remember_token', value='None', port=None, + port_specified=False, domain='www.example.com', + domain_specified=False, domain_initial_dot=False, path='/', + path_specified=True, secure=False, expires=None, + discard=True, comment=None, comment_url=None, + rest={'HttpOnly': None}, rfc2109=False) + + self.client.cookie_jar.set_cookie(c) r = self._get('/') self.assertNotIn('BadSignature', r.data) From aad042d7ad759709dfe2af71b5c77dc07be4541f Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Thu, 11 Oct 2012 17:11:48 -0400 Subject: [PATCH 234/234] Update changelog and install dependencies --- CHANGES | 9 +++++++++ setup.py | 16 ++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CHANGES b/CHANGES index 4226020b..34d405f8 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,15 @@ Flask-Security Changelog Here you can see the full list of changes between each Flask-Security release. +Version 1.5.0 +------------- + +Released October 11th 2012 + +- Major release. Upgrading from previous versions will require a bit of work to + accomodate API changes. See documentation for a list of new features and for + help on how to upgrade. + Version 1.2.3 ------------- diff --git a/setup.py b/setup.py index 6c423a42..ae2636b9 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ setup( name='Flask-Security', - version='1.3.0-dev', + version='1.5.0-dev', url='https://github.com/mattupstate/flask-security', license='MIT', author='Matt Wright', @@ -34,13 +34,13 @@ include_package_data=True, platforms='any', install_requires=[ - 'Flask>=0.9', - 'Flask-Login>=0.1.3', - 'Flask-Mail>=0.7.0', - 'Flask-Principal>=0.3', - 'Flask-WTF>=0.5.4', - 'itsdangerous>=0.15', - 'passlib>=1.5.3', + 'Flask>=0.8', + 'Flask-Login==0.1.3', + 'Flask-Mail==0.7.3', + 'Flask-Principal==0.3.3', + 'Flask-WTF==0.8', + 'itsdangerous==0.17', + 'passlib==1.6.1', ], test_suite='nose.collector', tests_require=[