diff --git a/docs/changelog.rst b/docs/changelog.rst index 2cd31d2..3343b05 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,6 +6,11 @@ Change Log All library changes, in descending order. +UNRELEASED +---------- + +- Add email verification implementation. + Version 0.4.8 ------------- diff --git a/flask_stormpath/__init__.py b/flask_stormpath/__init__.py index bd7798f..876634b 100644 --- a/flask_stormpath/__init__.py +++ b/flask_stormpath/__init__.py @@ -58,6 +58,9 @@ login, logout, register, + verify_email, + verify_email_tokens, + welcome, ) @@ -209,6 +212,26 @@ def init_routes(self, app): facebook_login, ) + if app.config['STORMPATH_VERIFY_EMAIL']: + app.add_url_rule( + app.config['STORMPATH_VERIFY_EMAIL_URL'], + 'stormpath.verify_email', + verify_email, + methods=['GET', 'POST'], + ) + + app.add_url_rule( + '/emailVerificationTokens', + 'stormpath.verify_email_tokens', + verify_email_tokens, + ) + + app.add_url_rule( + app.config['STORMPATH_WELCOME_URL'], + 'stormpath.welcome', + welcome, + ) + @property def client(self): """ diff --git a/flask_stormpath/forms.py b/flask_stormpath/forms.py index d99728c..fdc6831 100644 --- a/flask_stormpath/forms.py +++ b/flask_stormpath/forms.py @@ -3,7 +3,7 @@ from flask_wtf import FlaskForm from flask_wtf.form import _Auto -from wtforms.fields import PasswordField, StringField +from wtforms.fields import HiddenField, PasswordField, StringField from wtforms.validators import Email, EqualTo, InputRequired, ValidationError @@ -97,3 +97,7 @@ class ChangePasswordForm(FlaskForm): InputRequired('Please verify the password.'), EqualTo('password', 'Passwords do not match.') ]) + + +class ResendVerificationForm(FlaskForm): + username = HiddenField('Username') diff --git a/flask_stormpath/settings.py b/flask_stormpath/settings.py index 29c06c1..c6faa2c 100644 --- a/flask_stormpath/settings.py +++ b/flask_stormpath/settings.py @@ -60,6 +60,8 @@ def init_settings(config): # Configure URL mappings. These URL mappings control which URLs will be # used by Flask-Stormpath views. config.setdefault('STORMPATH_REGISTRATION_URL', '/register') + config.setdefault('STORMPATH_VERIFY_EMAIL_URL', '/verify_email') + config.setdefault('STORMPATH_WELCOME_URL', '/welcome') config.setdefault('STORMPATH_LOGIN_URL', '/login') config.setdefault('STORMPATH_LOGOUT_URL', '/logout') config.setdefault('STORMPATH_FORGOT_PASSWORD_URL', '/forgot') @@ -78,6 +80,10 @@ def init_settings(config): # used to render the Flask-Stormpath views. config.setdefault('STORMPATH_BASE_TEMPLATE', 'flask_stormpath/base.html') config.setdefault('STORMPATH_REGISTRATION_TEMPLATE', 'flask_stormpath/register.html') + config.setdefault('STORMPATH_VERIFY_EMAIL_TEMPLATE', 'flask_stormpath/verify_email.html') + config.setdefault('STORMPATH_VERIFY_EMAIL_SENT_TEMPLATE', 'flask_stormpath/verify_email_sent.html') + config.setdefault('STORMPATH_VERIFY_EMAIL_COMPLETE_TEMPLATE', 'flask_stormpath/verify_email_complete.html') + config.setdefault('STORMPATH_WELCOME_TEMPLATE', 'flask_stormpath/welcome.html') config.setdefault('STORMPATH_LOGIN_TEMPLATE', 'flask_stormpath/login.html') config.setdefault('STORMPATH_FORGOT_PASSWORD_TEMPLATE', 'flask_stormpath/forgot.html') config.setdefault('STORMPATH_FORGOT_PASSWORD_EMAIL_SENT_TEMPLATE', 'flask_stormpath/forgot_email_sent.html') diff --git a/flask_stormpath/templates/flask_stormpath/verify_email.html b/flask_stormpath/templates/flask_stormpath/verify_email.html new file mode 100644 index 0000000..bcd3938 --- /dev/null +++ b/flask_stormpath/templates/flask_stormpath/verify_email.html @@ -0,0 +1,46 @@ +{% extends config['STORMPATH_BASE_TEMPLATE'] %} + +{% block title %}Verify Email{% endblock %} +{% block description %}Verify your email address{% endblock %} +{% block bodytag %}login{% endblock %} + +{% block body %} +
+
+ +
+
+{% endblock %} diff --git a/flask_stormpath/templates/flask_stormpath/verify_email_complete.html b/flask_stormpath/templates/flask_stormpath/verify_email_complete.html new file mode 100644 index 0000000..31506cd --- /dev/null +++ b/flask_stormpath/templates/flask_stormpath/verify_email_complete.html @@ -0,0 +1,44 @@ +{% extends config['STORMPATH_BASE_TEMPLATE'] %} + +{% block title %}Email Verified!{% endblock %} +{% block description %}Email verification completed!{% endblock %} +{% block bodytag %}login{% endblock %} + +{% block body %} + +
+
+ +
+
+{% endblock %} diff --git a/flask_stormpath/templates/flask_stormpath/verify_email_sent.html b/flask_stormpath/templates/flask_stormpath/verify_email_sent.html new file mode 100644 index 0000000..8d93f6f --- /dev/null +++ b/flask_stormpath/templates/flask_stormpath/verify_email_sent.html @@ -0,0 +1,41 @@ +{% extends config['STORMPATH_BASE_TEMPLATE'] %} + +{% block title %}Email Sent!{% endblock %} +{% block description %}Verification email sent!{% endblock %} +{% block bodytag %}login{% endblock %} + +{% block body %} +
+
+ +
+
+{% endblock %} diff --git a/flask_stormpath/templates/flask_stormpath/welcome.html b/flask_stormpath/templates/flask_stormpath/welcome.html new file mode 100644 index 0000000..c2d5d0c --- /dev/null +++ b/flask_stormpath/templates/flask_stormpath/welcome.html @@ -0,0 +1,42 @@ +{% extends config['STORMPATH_BASE_TEMPLATE'] %} + +{% block title %}Welcome!{% endblock %} +{% block description %}Welcome!{% endblock %} +{% block bodytag %}login{% endblock %} + +{% block body %} +
+
+ +
+
+{% endblock %} diff --git a/flask_stormpath/views.py b/flask_stormpath/views.py index f00f4a5..50c39b8 100644 --- a/flask_stormpath/views.py +++ b/flask_stormpath/views.py @@ -10,6 +10,7 @@ redirect, render_template, request, + session, ) from flask_login import login_user from six import string_types @@ -21,6 +22,7 @@ ForgotPasswordForm, LoginForm, RegistrationForm, + ResendVerificationForm, ) from .models import User @@ -58,6 +60,11 @@ def register(): data.get('surname', 'Anonymous') or 'Anonymous', **optional_params ) + if account.is_unverified(): + # Don't log in if the account has not been verified yet. + return redirect( + current_app.config['STORMPATH_WELCOME_URL'] + ) # If we're able to successfully create the user's account, # we'll log the user in (creating a secure session using @@ -66,7 +73,7 @@ def register(): login_user(account, remember=True) # The email address must be verified, so pop an alert about it. - if current_app.config['STORMPATH_VERIFY_EMAIL'] is True: + if account.is_unverified() and current_app.config['STORMPATH_VERIFY_EMAIL'] is True: flash('You must validate your email address before logging in. Please check your email for instructions.') if 'STORMPATH_REGISTRATION_REDIRECT_URL' in current_app.config: @@ -112,6 +119,25 @@ def login(): login_user(account, remember=True) return redirect(request.args.get('next') or current_app.config['STORMPATH_REDIRECT_URL']) + + except StormpathError as err: + if err.code == 7102: + # User's email has not been verified yet + session['verify_email_for'] = form.login.data + + return redirect( + current_app.config['STORMPATH_VERIFY_EMAIL_URL'] + ) + else: + flash(err.message) + + # Pre-fill fields with the username, if it is available. + href = request.args.get('href') + if href: + try: + account = current_app.stormpath_manager.client.accounts.get(href) + form.login.data = account.username + except StormpathError as err: flash(err.message) @@ -391,3 +417,40 @@ def logout(): """ logout_user() return redirect('/') + + +def welcome(): + return render_template(current_app.config['STORMPATH_WELCOME_TEMPLATE']) + + +def verify_email(): + form = ResendVerificationForm() + + if form.validate_on_submit(): + try: + account = current_app.stormpath_manager.application.accounts.search({'username': form.username.data})[0] + current_app.stormpath_manager.application.verification_emails.resend(account, account.directory) + + return render_template( + current_app.config['STORMPATH_VERIFY_EMAIL_SENT_TEMPLATE'] + ) + except StormpathError as err: + flash(err.message) + abort(400) + + form.username.data = session['verify_email_for'] + return render_template( + current_app.config['STORMPATH_VERIFY_EMAIL_TEMPLATE'], + form=form + ) + + +def verify_email_tokens(): + try: + account = current_app.stormpath_manager.client.accounts.verify_email_token(request.args.get('sptoken')) + return render_template( + current_app.config['STORMPATH_VERIFY_EMAIL_COMPLETE_TEMPLATE'], href=account.href + ) + except StormpathError as err: + flash(err.message) + abort(400) \ No newline at end of file diff --git a/tests/test_views.py b/tests/test_views.py index 1aa8186..6f989aa 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -285,6 +285,34 @@ def test_redirect_to_register_url(self): self.assertTrue('redirect_for_login' in location) self.assertFalse('redirect_for_registration' in location) + def test_redirect_to_verify_email_url(self): + # Enable email verification + self.app.config['STORMPATH_VERIFY_EMAIL'] = True + account_creation_policy = self.client.directories.search(self.application.name).items[0].account_creation_policy + account_creation_policy.verification_email_status = 'ENABLED' + # account_creation_policy.verification_success_email_status = 'ENABLED' + account_creation_policy.save() + + # Create a user. + with self.app.app_context(): + User.create( + username = 'rdegges', + given_name = 'Randall', + surname = 'Degges', + email = 'r@testmail.stormpath.com', + password = 'woot1LoveCookies!', + ) + + with self.app.test_client() as c: + # Attempt a login using username and password. + resp = c.post( + '/login', + data={'login': 'rdegges', 'password': 'woot1LoveCookies!',}) + + self.assertEqual(resp.status_code, 302) + location = resp.headers.get('location') + self.assertTrue('verify_email' in location) + class TestLogout(StormpathTestCase): """Test our logout view."""