diff --git a/example/app.py b/example/app.py index 3ec090a..bbe1b4a 100644 --- a/example/app.py +++ b/example/app.py @@ -136,13 +136,8 @@ def create_sqlalchemy_app(auth_config=None): 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 @@ def create_mongoengine_app(auth_config=None): 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 7d87220..aadb551 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') + 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) + + except BadSignature: + 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) - - # TODO: Clear confirmation_token after confirmation? - #user.confirmation_token = None - #user.confirmation_sent_at = None 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 603a91a..9044bc3 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 passlib.context import CryptContext 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 @@ _default_config = { '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 @@ _default_config = { '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 @@ class Security(object): 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 @@ class AuthenticationProvider(object): 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 4746102..3ba4447 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -90,9 +90,6 @@ class UserDatastore(object): 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 1b780c7..b8cd094 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -66,23 +66,11 @@ class RegisterForm(Form, 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 3fb319d..7fb6fbe 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 @@ _security = LocalProxy(lambda: app.security) _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 send_password_reset_notice(user): + """Sends the password reset notice email for the specified user. + + :param user: The user to send the notice to + """ + send_mail('Your password has been reset', + user.email, + 'reset_notice', + dict(user=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: - 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 + data = [user.id, md5(user.password)] + return _security.reset_serializer.dumps(data) -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() - get_within_delta('RESET_PASSWORD_WITHIN') - return user.reset_password_sent_at < token_expires - - -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 e290290..3e43e74 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 flask.ext.login import make_secure_token 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 = [] + registrations = [] - 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) + 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 = [] + reset_requests = [] - 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) + 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 9e60b93..bbbd338 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -17,8 +17,7 @@ from flask.ext.login import login_user, logout_user 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 2817ea3..634f2ff 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 @@ class RegisterableTests(SecurityTest): 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 @@ class ConfirmableTests(SecurityTest): 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 @@ class LoginWithoutImmediateConfirmTests(SecurityTest): 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 @@ class RecoverableTests(SecurityTest): 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)