From 4c1a16e2ee102f58ed70d180224d1cf51651f81a Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 11 May 2012 18:15:46 -0400 Subject: [PATCH] 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 a2c8ff8..02c6810 100644 --- a/example/app.py +++ b/example/app.py @@ -93,7 +93,7 @@ def create_app(auth_config): 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 @@ def create_sqlalchemy_app(auth_config=None): 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 64ea932..3253da2 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 @@ 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') + 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 9e1736a..c3bd994 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -42,6 +42,7 @@ _default_config = { '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 @@ class Security(object): 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 1c3d3c6..6bab2e0 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -91,6 +91,7 @@ class UserDatastore(object): :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 f15d4ea..996fa3b 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 0000000..6c257ad --- /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 ca9ae6b..071f5ab 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 6540257..7d09bb3 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 0000000..e69de29 diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 40def55..8eca04b 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 @@ class ConfiguredURLTests(SecurityTest): 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 @@ class ConfirmationTests(SecurityTest): 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):