From 330e5fb9339d60036f269659b68ece96fc6ca396 Mon Sep 17 00:00:00 2001 From: tmeissner Date: Mon, 5 Nov 2018 00:07:07 +0100 Subject: [PATCH] Chapter 8: Account confirmation (8e) --- app/auth/views.py | 51 ++++++++++++++++++++++++--- app/models.py | 20 +++++++++++ app/templates/auth/email/confirm.html | 8 +++++ app/templates/auth/email/confirm.txt | 13 +++++++ app/templates/auth/unconfirmed.html | 20 +++++++++++ migrations/versions/f8108bd4cd89_.py | 28 +++++++++++++++ tests/test_user_model.py | 26 ++++++++++++++ 7 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 app/templates/auth/email/confirm.html create mode 100644 app/templates/auth/email/confirm.txt create mode 100644 app/templates/auth/unconfirmed.html create mode 100644 migrations/versions/f8108bd4cd89_.py diff --git a/app/auth/views.py b/app/auth/views.py index e892cd2..0f1bb20 100644 --- a/app/auth/views.py +++ b/app/auth/views.py @@ -1,11 +1,28 @@ from flask import render_template, redirect, request, url_for, flash -from flask_login import login_user, logout_user, login_required +from flask_login import login_user, logout_user, login_required, current_user from . import auth from .. import db from ..models import User +from ..email import send_email from .forms import LoginForm, RegistrationForm +@auth.before_app_request +def before_request(): + if current_user.is_authenticated \ + and not current_user.confirmed \ + and request.blueprint != 'auth' \ + and request.endpoint != 'static': + return redirect(url_for('auth.unconfirmed')) + + +@auth.route('/unconfirmed') +def unconfirmed(): + if current_user.is_anonymous or current_user.confirmed: + return redirect(url_for('main.index')) + return render_template('auth/unconfirmed.html') + + @auth.route('/login', methods=['GET', 'POST']) def login(): form = LoginForm() @@ -38,6 +55,32 @@ def register(): password=form.password.data) db.session.add(user) db.session.commit() - flash('You can now login.') - return redirect(url_for('auth.login')) - return render_template('auth/register.html', form=form) \ No newline at end of file + token = user.generate_confirmation_token() + send_email(user.email, 'Confirm your account', + 'auth/email/confirm', user=user, token=token) + flash('A confirmation email has been sent to you by email.') + return redirect(url_for('main.index')) + return render_template('auth/register.html', form=form) + + +@auth.route('/confirm/') +@login_required +def confirm(token): + if current_user.confirmed: + return redirect(url_for('main.index')) + if current_user.confirm(token): + db.session.commit() + flash('You have confirmed your account. Thanks!') + else: + flash('The confirmationlink is invalid or has expired') + return redirect(url_for('main.index')) + + +@auth.route('/confirm') +@login_required +def resend_confirmation(): + token = current_user.generate_confirmation_token() + send_email(current_user.email, 'Confirm your account', + 'auth/email/confirm', user=current_user, token=token) + flash('A new confirmation email has been sent to you by email.') + return redirect(url_for('main.index')) diff --git a/app/models.py b/app/models.py index 6d69b5a..f7c7c3b 100644 --- a/app/models.py +++ b/app/models.py @@ -1,4 +1,7 @@ from werkzeug.security import generate_password_hash, check_password_hash +from itsdangerous import TimedJSONWebSignatureSerializer as Serializer +from itsdangerous import BadSignature +from flask import current_app from flask_login import UserMixin from . import db, login_manager @@ -20,6 +23,7 @@ class User(UserMixin, db.Model): username = db.Column(db.String(64), unique=True, index=True) role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) password_hash = db.Column(db.String(128)) + confirmed = db.Column(db.Boolean, default=False) @property def password(self): @@ -32,6 +36,22 @@ class User(UserMixin, db.Model): def verify_password(self, password): return check_password_hash(self.password_hash, password) + def generate_confirmation_token(self, expiration=3600): + s = Serializer(current_app.config['SECRET_KEY'], expiration) + return s.dumps({'confirm': self.id}).decode('utf-8') + + def confirm(self, token): + s = Serializer(current_app.config['SECRET_KEY']) + try: + data = s.loads(token.encode('utf-8')) + except BadSignature: + return False + if data.get('confirm') != self.id: + return False + self.confirmed = True + db.session.add(self) + return True + def __repr__(self): return '' % self.username diff --git a/app/templates/auth/email/confirm.html b/app/templates/auth/email/confirm.html new file mode 100644 index 0000000..e15e221 --- /dev/null +++ b/app/templates/auth/email/confirm.html @@ -0,0 +1,8 @@ +

Dear {{ user.username }},

+

Welcome to Flasky!

+

To confirm your account please click here.

+

Alternatively, you can paste the following link in your browser's address bar:

+

{{ url_for('auth.confirm', token=token, _external=True) }}

+

Sincerely,

+

The Flasky Team

+

Note: replies to this email address are not monitored.

diff --git a/app/templates/auth/email/confirm.txt b/app/templates/auth/email/confirm.txt new file mode 100644 index 0000000..f506d11 --- /dev/null +++ b/app/templates/auth/email/confirm.txt @@ -0,0 +1,13 @@ +Dear {{ user.username }}, + +Welcome to Flasky! + +To confirm your account please klick on the following link: + +{{ url_for('auth.confirm', token=token, _external=True) }} + +Sincerely, + +The Flasky Team + +Note: replies to this email address are not monitored. diff --git a/app/templates/auth/unconfirmed.html b/app/templates/auth/unconfirmed.html new file mode 100644 index 0000000..75bf19a --- /dev/null +++ b/app/templates/auth/unconfirmed.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block title %}Flasky - Confirm your account{% endblock %} + +{% block page_content %} + +{% endblock %} diff --git a/migrations/versions/f8108bd4cd89_.py b/migrations/versions/f8108bd4cd89_.py new file mode 100644 index 0000000..2255283 --- /dev/null +++ b/migrations/versions/f8108bd4cd89_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: f8108bd4cd89 +Revises: 655013143dbb +Create Date: 2018-11-04 23:53:16.391929 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f8108bd4cd89' +down_revision = '655013143dbb' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('confirmed', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'confirmed') + # ### end Alembic commands ### diff --git a/tests/test_user_model.py b/tests/test_user_model.py index b705a3b..5c7683f 100644 --- a/tests/test_user_model.py +++ b/tests/test_user_model.py @@ -1,4 +1,5 @@ import unittest +import time from app import create_app, db from app.models import User @@ -33,3 +34,28 @@ class UserModelTestCase(unittest.TestCase): u = User(password='cat') u2 = User(password='cat') self.assertTrue(u.password_hash != u2.password_hash) + + + def test_valid_confirmation_token(self): + u = User(password='cat') + db.session.add(u) + db.session.commit() + token = u.generate_confirmation_token() + self.assertTrue(u.confirm(token)) + + def test_invalid_confirmation_token(self): + u1 = User(password='cat') + u2 = User(password='dog') + db.session.add(u1) + db.session.add(u2) + db.session.commit() + token = u1.generate_confirmation_token() + self.assertFalse(u2.confirm(token)) + + def test_expired_confirmation_token(self): + u = User(password='cat') + db.session.add(u) + db.session.commit() + token = u.generate_confirmation_token(1) + time.sleep(2) + self.assertFalse(u.confirm(token))