From c786cd15818c6e2d3895aeb4f13595714d3fce06 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Tue, 18 Jul 2017 07:55:15 -0700 Subject: [PATCH 01/45] Chapter 7: Large file structure (7a) --- app/__init__.py | 28 +++++ app/email.py | 20 ++++ app/main/__init__.py | 5 + app/main/errors.py | 12 ++ app/main/forms.py | 8 ++ app/main/views.py | 28 +++++ app/models.py | 21 ++++ {static => app/static}/favicon.ico | Bin {templates => app/templates}/404.html | 0 {templates => app/templates}/500.html | 0 {templates => app/templates}/base.html | 4 +- {templates => app/templates}/index.html | 0 .../templates}/mail/new_user.html | 0 .../templates}/mail/new_user.txt | 0 config.py | 46 ++++++++ flasky.py | 25 ++++ hello.py | 109 ------------------ requirements.txt | 22 ++++ tests/__init__.py | 0 tests/test_basics.py | 22 ++++ 20 files changed, 239 insertions(+), 111 deletions(-) create mode 100644 app/__init__.py create mode 100644 app/email.py create mode 100644 app/main/__init__.py create mode 100644 app/main/errors.py create mode 100644 app/main/forms.py create mode 100644 app/main/views.py create mode 100644 app/models.py rename {static => app/static}/favicon.ico (100%) rename {templates => app/templates}/404.html (100%) rename {templates => app/templates}/500.html (100%) rename {templates => app/templates}/base.html (89%) rename {templates => app/templates}/index.html (100%) rename {templates => app/templates}/mail/new_user.html (100%) rename {templates => app/templates}/mail/new_user.txt (100%) create mode 100644 config.py create mode 100644 flasky.py delete mode 100644 hello.py create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_basics.py diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 000000000..4ca4c4145 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,28 @@ +from flask import Flask +from flask_bootstrap import Bootstrap +from flask_mail import Mail +from flask_moment import Moment +from flask_sqlalchemy import SQLAlchemy +from config import config + +bootstrap = Bootstrap() +mail = Mail() +moment = Moment() +db = SQLAlchemy() + + +def create_app(config_name): + app = Flask(__name__) + app.config.from_object(config[config_name]) + config[config_name].init_app(app) + + bootstrap.init_app(app) + mail.init_app(app) + moment.init_app(app) + db.init_app(app) + + from .main import main as main_blueprint + app.register_blueprint(main_blueprint) + + return app + diff --git a/app/email.py b/app/email.py new file mode 100644 index 000000000..0f6ac520b --- /dev/null +++ b/app/email.py @@ -0,0 +1,20 @@ +from threading import Thread +from flask import current_app, render_template +from flask_mail import Message +from . import mail + + +def send_async_email(app, msg): + with app.app_context(): + mail.send(msg) + + +def send_email(to, subject, template, **kwargs): + app = current_app._get_current_object() + msg = Message(app.config['FLASKY_MAIL_SUBJECT_PREFIX'] + ' ' + subject, + sender=app.config['FLASKY_MAIL_SENDER'], recipients=[to]) + msg.body = render_template(template + '.txt', **kwargs) + msg.html = render_template(template + '.html', **kwargs) + thr = Thread(target=send_async_email, args=[app, msg]) + thr.start() + return thr diff --git a/app/main/__init__.py b/app/main/__init__.py new file mode 100644 index 000000000..90380f84d --- /dev/null +++ b/app/main/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +main = Blueprint('main', __name__) + +from . import views, errors diff --git a/app/main/errors.py b/app/main/errors.py new file mode 100644 index 000000000..7c76c776d --- /dev/null +++ b/app/main/errors.py @@ -0,0 +1,12 @@ +from flask import render_template +from . import main + + +@main.app_errorhandler(404) +def page_not_found(e): + return render_template('404.html'), 404 + + +@main.app_errorhandler(500) +def internal_server_error(e): + return render_template('500.html'), 500 diff --git a/app/main/forms.py b/app/main/forms.py new file mode 100644 index 000000000..2ca927755 --- /dev/null +++ b/app/main/forms.py @@ -0,0 +1,8 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField +from wtforms.validators import DataRequired + + +class NameForm(FlaskForm): + name = StringField('What is your name?', validators=[DataRequired()]) + submit = SubmitField('Submit') diff --git a/app/main/views.py b/app/main/views.py new file mode 100644 index 000000000..2440eb273 --- /dev/null +++ b/app/main/views.py @@ -0,0 +1,28 @@ +from flask import render_template, session, redirect, url_for, current_app +from .. import db +from ..models import User +from ..email import send_email +from . import main +from .forms import NameForm + + +@main.route('/', methods=['GET', 'POST']) +def index(): + form = NameForm() + if form.validate_on_submit(): + user = User.query.filter_by(username=form.name.data).first() + if user is None: + user = User(username=form.name.data) + db.session.add(user) + db.session.commit() + session['known'] = False + if current_app.config['FLASKY_ADMIN']: + send_email(current_app.config['FLASKY_ADMIN'], 'New User', + 'mail/new_user', user=user) + else: + session['known'] = True + session['name'] = form.name.data + return redirect(url_for('.index')) + return render_template('index.html', + form=form, name=session.get('name'), + known=session.get('known', False)) diff --git a/app/models.py b/app/models.py new file mode 100644 index 000000000..5c885d668 --- /dev/null +++ b/app/models.py @@ -0,0 +1,21 @@ +from . import db + + +class Role(db.Model): + __tablename__ = 'roles' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(64), unique=True) + users = db.relationship('User', backref='role', lazy='dynamic') + + def __repr__(self): + return '' % self.name + + +class User(db.Model): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(64), unique=True, index=True) + role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) + + def __repr__(self): + return '' % self.username diff --git a/static/favicon.ico b/app/static/favicon.ico similarity index 100% rename from static/favicon.ico rename to app/static/favicon.ico diff --git a/templates/404.html b/app/templates/404.html similarity index 100% rename from templates/404.html rename to app/templates/404.html diff --git a/templates/500.html b/app/templates/500.html similarity index 100% rename from templates/500.html rename to app/templates/500.html diff --git a/templates/base.html b/app/templates/base.html similarity index 89% rename from templates/base.html rename to app/templates/base.html index 92ef01d69..17b38fcaf 100644 --- a/templates/base.html +++ b/app/templates/base.html @@ -18,11 +18,11 @@ - Flasky + Flasky diff --git a/templates/index.html b/app/templates/index.html similarity index 100% rename from templates/index.html rename to app/templates/index.html diff --git a/templates/mail/new_user.html b/app/templates/mail/new_user.html similarity index 100% rename from templates/mail/new_user.html rename to app/templates/mail/new_user.html diff --git a/templates/mail/new_user.txt b/app/templates/mail/new_user.txt similarity index 100% rename from templates/mail/new_user.txt rename to app/templates/mail/new_user.txt diff --git a/config.py b/config.py new file mode 100644 index 000000000..235923839 --- /dev/null +++ b/config.py @@ -0,0 +1,46 @@ +import os +basedir = os.path.abspath(os.path.dirname(__file__)) + + +class Config: + SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard to guess string' + MAIL_SERVER = os.environ.get('MAIL_SERVER', 'smtp.googlemail.com') + MAIL_PORT = int(os.environ.get('MAIL_PORT', '587')) + MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in \ + ['true', 'on', '1'] + MAIL_USERNAME = os.environ.get('MAIL_USERNAME') + MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') + FLASKY_MAIL_SUBJECT_PREFIX = '[Flasky]' + FLASKY_MAIL_SENDER = 'Flasky Admin ' + FLASKY_ADMIN = os.environ.get('FLASKY_ADMIN') + SQLALCHEMY_TRACK_MODIFICATIONS = False + + @staticmethod + def init_app(app): + pass + + +class DevelopmentConfig(Config): + DEBUG = True + SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \ + 'sqlite:///' + os.path.join(basedir, 'data-dev.sqlite') + + +class TestingConfig(Config): + TESTING = True + SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \ + 'sqlite://' + + +class ProductionConfig(Config): + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ + 'sqlite:///' + os.path.join(basedir, 'data.sqlite') + + +config = { + 'development': DevelopmentConfig, + 'testing': TestingConfig, + 'production': ProductionConfig, + + 'default': DevelopmentConfig +} diff --git a/flasky.py b/flasky.py new file mode 100644 index 000000000..8a4d1adca --- /dev/null +++ b/flasky.py @@ -0,0 +1,25 @@ +import os +import click +from flask_migrate import Migrate +from app import create_app, db +from app.models import User, Role + +app = create_app(os.getenv('FLASK_CONFIG') or 'default') +migrate = Migrate(app, db) + + +@app.shell_context_processor +def make_shell_context(): + return dict(db=db, User=User, Role=Role) + + +@app.cli.command() +@click.argument('test_names', nargs=-1) +def test(test_names): + """Run the unit tests.""" + import unittest + if test_names: + tests = unittest.TestLoader().loadTestsFromNames(test_names) + else: + tests = unittest.TestLoader().discover('tests') + unittest.TextTestRunner(verbosity=2).run(tests) diff --git a/hello.py b/hello.py deleted file mode 100644 index 7e0e58f3a..000000000 --- a/hello.py +++ /dev/null @@ -1,109 +0,0 @@ -import os -from threading import Thread -from flask import Flask, render_template, session, redirect, url_for -from flask_bootstrap import Bootstrap -from flask_moment import Moment -from flask_wtf import FlaskForm -from wtforms import StringField, SubmitField -from wtforms.validators import DataRequired -from flask_sqlalchemy import SQLAlchemy -from flask_migrate import Migrate -from flask_mail import Mail, Message - -basedir = os.path.abspath(os.path.dirname(__file__)) - -app = Flask(__name__) -app.config['SECRET_KEY'] = 'hard to guess string' -app.config['SQLALCHEMY_DATABASE_URI'] =\ - 'sqlite:///' + os.path.join(basedir, 'data.sqlite') -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False -app.config['MAIL_SERVER'] = 'smtp.googlemail.com' -app.config['MAIL_PORT'] = 587 -app.config['MAIL_USE_TLS'] = True -app.config['MAIL_USERNAME'] = os.environ.get('MAIL_USERNAME') -app.config['MAIL_PASSWORD'] = os.environ.get('MAIL_PASSWORD') -app.config['FLASKY_MAIL_SUBJECT_PREFIX'] = '[Flasky]' -app.config['FLASKY_MAIL_SENDER'] = 'Flasky Admin ' -app.config['FLASKY_ADMIN'] = os.environ.get('FLASKY_ADMIN') - -bootstrap = Bootstrap(app) -moment = Moment(app) -db = SQLAlchemy(app) -migrate = Migrate(app, db) -mail = Mail(app) - - -class Role(db.Model): - __tablename__ = 'roles' - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(64), unique=True) - users = db.relationship('User', backref='role', lazy='dynamic') - - def __repr__(self): - return '' % self.name - - -class User(db.Model): - __tablename__ = 'users' - id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(64), unique=True, index=True) - role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) - - def __repr__(self): - return '' % self.username - - -def send_async_email(app, msg): - with app.app_context(): - mail.send(msg) - - -def send_email(to, subject, template, **kwargs): - msg = Message(app.config['FLASKY_MAIL_SUBJECT_PREFIX'] + ' ' + subject, - sender=app.config['FLASKY_MAIL_SENDER'], recipients=[to]) - msg.body = render_template(template + '.txt', **kwargs) - msg.html = render_template(template + '.html', **kwargs) - thr = Thread(target=send_async_email, args=[app, msg]) - thr.start() - return thr - - -class NameForm(FlaskForm): - name = StringField('What is your name?', validators=[DataRequired()]) - submit = SubmitField('Submit') - - -@app.shell_context_processor -def make_shell_context(): - return dict(db=db, User=User, Role=Role) - - -@app.errorhandler(404) -def page_not_found(e): - return render_template('404.html'), 404 - - -@app.errorhandler(500) -def internal_server_error(e): - return render_template('500.html'), 500 - - -@app.route('/', methods=['GET', 'POST']) -def index(): - form = NameForm() - if form.validate_on_submit(): - user = User.query.filter_by(username=form.name.data).first() - if user is None: - user = User(username=form.name.data) - db.session.add(user) - db.session.commit() - session['known'] = False - if app.config['FLASKY_ADMIN']: - send_email(app.config['FLASKY_ADMIN'], 'New User', - 'mail/new_user', user=user) - else: - session['known'] = True - session['name'] = form.name.data - return redirect(url_for('index')) - return render_template('index.html', form=form, name=session.get('name'), - known=session.get('known', False)) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..1434adb59 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,22 @@ +alembic==0.9.3 +blinker==1.4 +click==6.7 +dominate==2.3.1 +Flask==0.12.2 +Flask-Bootstrap==3.3.7.1 +Flask-Mail==0.9.1 +Flask-Migrate==2.0.4 +Flask-Moment==0.5.1 +Flask-SQLAlchemy==2.2 +Flask-WTF==0.14.2 +itsdangerous==0.24 +Jinja2==2.9.6 +Mako==1.0.7 +MarkupSafe==1.1.1 +python-dateutil==2.6.1 +python-editor==1.0.3 +six==1.10.0 +SQLAlchemy==1.1.11 +visitor==0.1.3 +Werkzeug==0.12.2 +WTForms==2.1 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_basics.py b/tests/test_basics.py new file mode 100644 index 000000000..0fdf4983b --- /dev/null +++ b/tests/test_basics.py @@ -0,0 +1,22 @@ +import unittest +from flask import current_app +from app import create_app, db + + +class BasicsTestCase(unittest.TestCase): + def setUp(self): + self.app = create_app('testing') + self.app_context = self.app.app_context() + self.app_context.push() + db.create_all() + + def tearDown(self): + db.session.remove() + db.drop_all() + self.app_context.pop() + + def test_app_exists(self): + self.assertFalse(current_app is None) + + def test_app_is_testing(self): + self.assertTrue(current_app.config['TESTING']) From e88ac5a6e8d312ce4c5d0c96a5403a0fc22bf76b Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Tue, 18 Jul 2017 07:55:16 -0700 Subject: [PATCH 02/45] Chapter 8: Password hashing with Werkzeug (8a) --- app/models.py | 13 +++++++++++++ tests/test_user_model.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 tests/test_user_model.py diff --git a/app/models.py b/app/models.py index 5c885d668..c938f0aff 100644 --- a/app/models.py +++ b/app/models.py @@ -1,3 +1,4 @@ +from werkzeug.security import generate_password_hash, check_password_hash from . import db @@ -16,6 +17,18 @@ class User(db.Model): id = db.Column(db.Integer, primary_key=True) 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)) + + @property + def password(self): + raise AttributeError('password is not a readable attribute') + + @password.setter + def password(self, password): + self.password_hash = generate_password_hash(password) + + def verify_password(self, password): + return check_password_hash(self.password_hash, password) def __repr__(self): return '' % self.username diff --git a/tests/test_user_model.py b/tests/test_user_model.py new file mode 100644 index 000000000..b705a3bcf --- /dev/null +++ b/tests/test_user_model.py @@ -0,0 +1,35 @@ +import unittest +from app import create_app, db +from app.models import User + + +class UserModelTestCase(unittest.TestCase): + def setUp(self): + self.app = create_app('testing') + self.app_context = self.app.app_context() + self.app_context.push() + db.create_all() + + def tearDown(self): + db.session.remove() + db.drop_all() + self.app_context.pop() + + def test_password_setter(self): + u = User(password='cat') + self.assertTrue(u.password_hash is not None) + + def test_no_password_getter(self): + u = User(password='cat') + with self.assertRaises(AttributeError): + u.password + + def test_password_verification(self): + u = User(password='cat') + self.assertTrue(u.verify_password('cat')) + self.assertFalse(u.verify_password('dog')) + + def test_password_salts_are_random(self): + u = User(password='cat') + u2 = User(password='cat') + self.assertTrue(u.password_hash != u2.password_hash) From 2a8423aa8138fec81367ad08e16c3a4e774f81eb Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Tue, 18 Jul 2017 07:55:17 -0700 Subject: [PATCH 03/45] Chapter 8: Authentication blueprint (8b) --- app/__init__.py | 3 +++ app/auth/__init__.py | 5 +++++ app/auth/views.py | 7 +++++++ app/templates/auth/login.html | 9 +++++++++ 4 files changed, 24 insertions(+) create mode 100644 app/auth/__init__.py create mode 100644 app/auth/views.py create mode 100644 app/templates/auth/login.html diff --git a/app/__init__.py b/app/__init__.py index 4ca4c4145..968cc319b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -24,5 +24,8 @@ def create_app(config_name): from .main import main as main_blueprint app.register_blueprint(main_blueprint) + from .auth import auth as auth_blueprint + app.register_blueprint(auth_blueprint, url_prefix='/auth') + return app diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 000000000..e54b37dc2 --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +auth = Blueprint('auth', __name__) + +from . import views diff --git a/app/auth/views.py b/app/auth/views.py new file mode 100644 index 000000000..50109e0a4 --- /dev/null +++ b/app/auth/views.py @@ -0,0 +1,7 @@ +from flask import render_template +from . import auth + + +@auth.route('/login') +def login(): + return render_template('auth/login.html') diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 000000000..237fbf23b --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block title %}Flasky - Login{% endblock %} + +{% block page_content %} + +{% endblock %} \ No newline at end of file From f958fae21602e758ed8c4679f811b139005d76ce Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Tue, 18 Jul 2017 07:55:18 -0700 Subject: [PATCH 04/45] Chapter 8: Login and logout with Flask-Login (8c) --- app/__init__.py | 6 +++- app/auth/forms.py | 11 +++++++ app/auth/views.py | 27 +++++++++++++++-- app/main/views.py | 27 ++--------------- app/models.py | 11 +++++-- app/templates/auth/login.html | 6 +++- app/templates/base.html | 7 +++++ app/templates/index.html | 9 +----- .../versions/456a945560f6_login_support.py | 30 +++++++++++++++++++ requirements.txt | 1 + 10 files changed, 96 insertions(+), 39 deletions(-) create mode 100644 app/auth/forms.py create mode 100644 migrations/versions/456a945560f6_login_support.py diff --git a/app/__init__.py b/app/__init__.py index 968cc319b..07718afaa 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,6 +3,7 @@ from flask_mail import Mail from flask_moment import Moment from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager from config import config bootstrap = Bootstrap() @@ -10,6 +11,9 @@ moment = Moment() db = SQLAlchemy() +login_manager = LoginManager() +login_manager.login_view = 'auth.login' + def create_app(config_name): app = Flask(__name__) @@ -20,6 +24,7 @@ def create_app(config_name): mail.init_app(app) moment.init_app(app) db.init_app(app) + login_manager.init_app(app) from .main import main as main_blueprint app.register_blueprint(main_blueprint) @@ -28,4 +33,3 @@ def create_app(config_name): app.register_blueprint(auth_blueprint, url_prefix='/auth') return app - diff --git a/app/auth/forms.py b/app/auth/forms.py new file mode 100644 index 000000000..d50cf956b --- /dev/null +++ b/app/auth/forms.py @@ -0,0 +1,11 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, BooleanField, SubmitField +from wtforms.validators import DataRequired, Length, Email + + +class LoginForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Length(1, 64), + Email()]) + password = PasswordField('Password', validators=[DataRequired()]) + remember_me = BooleanField('Keep me logged in') + submit = SubmitField('Log In') diff --git a/app/auth/views.py b/app/auth/views.py index 50109e0a4..395254359 100644 --- a/app/auth/views.py +++ b/app/auth/views.py @@ -1,7 +1,28 @@ -from flask import render_template +from flask import render_template, redirect, request, url_for, flash +from flask_login import login_user, logout_user, login_required from . import auth +from ..models import User +from .forms import LoginForm -@auth.route('/login') +@auth.route('/login', methods=['GET', 'POST']) def login(): - return render_template('auth/login.html') + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data.lower()).first() + if user is not None and user.verify_password(form.password.data): + login_user(user, form.remember_me.data) + next = request.args.get('next') + if next is None or not next.startswith('/'): + next = url_for('main.index') + return redirect(next) + flash('Invalid email or password.') + return render_template('auth/login.html', form=form) + + +@auth.route('/logout') +@login_required +def logout(): + logout_user() + flash('You have been logged out.') + return redirect(url_for('main.index')) diff --git a/app/main/views.py b/app/main/views.py index 2440eb273..c8520dea6 100644 --- a/app/main/views.py +++ b/app/main/views.py @@ -1,28 +1,7 @@ -from flask import render_template, session, redirect, url_for, current_app -from .. import db -from ..models import User -from ..email import send_email +from flask import render_template from . import main -from .forms import NameForm -@main.route('/', methods=['GET', 'POST']) +@main.route('/') def index(): - form = NameForm() - if form.validate_on_submit(): - user = User.query.filter_by(username=form.name.data).first() - if user is None: - user = User(username=form.name.data) - db.session.add(user) - db.session.commit() - session['known'] = False - if current_app.config['FLASKY_ADMIN']: - send_email(current_app.config['FLASKY_ADMIN'], 'New User', - 'mail/new_user', user=user) - else: - session['known'] = True - session['name'] = form.name.data - return redirect(url_for('.index')) - return render_template('index.html', - form=form, name=session.get('name'), - known=session.get('known', False)) + return render_template('index.html') diff --git a/app/models.py b/app/models.py index c938f0aff..729819dbf 100644 --- a/app/models.py +++ b/app/models.py @@ -1,5 +1,6 @@ from werkzeug.security import generate_password_hash, check_password_hash -from . import db +from flask_login import UserMixin +from . import db, login_manager class Role(db.Model): @@ -12,9 +13,10 @@ def __repr__(self): return '' % self.name -class User(db.Model): +class User(UserMixin, db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(64), unique=True, index=True) 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)) @@ -32,3 +34,8 @@ def verify_password(self, password): def __repr__(self): return '' % self.username + + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index 237fbf23b..476cff57c 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} {% block title %}Flasky - Login{% endblock %} @@ -6,4 +7,7 @@ -{% endblock %} \ No newline at end of file +
+ {{ wtf.quick_form(form) }} +
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 17b38fcaf..bc2c94fe2 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -24,6 +24,13 @@ + diff --git a/app/templates/index.html b/app/templates/index.html index b5657a7f5..90cebeb7a 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,16 +1,9 @@ {% extends "base.html" %} -{% import "bootstrap/wtf.html" as wtf %} {% block title %}Flasky{% endblock %} {% block page_content %} -{{ wtf.quick_form(form) }} {% endblock %} diff --git a/migrations/versions/456a945560f6_login_support.py b/migrations/versions/456a945560f6_login_support.py new file mode 100644 index 000000000..bb75e5097 --- /dev/null +++ b/migrations/versions/456a945560f6_login_support.py @@ -0,0 +1,30 @@ +"""login support + +Revision ID: 456a945560f6 +Revises: 38c4e85512a9 +Create Date: 2013-12-29 00:18:35.795259 + +""" + +# revision identifiers, used by Alembic. +revision = '456a945560f6' +down_revision = '38c4e85512a9' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('email', sa.String(length=64), nullable=True)) + op.add_column('users', sa.Column('password_hash', sa.String(length=128), nullable=True)) + op.create_index('ix_users_email', 'users', ['email'], unique=True) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_users_email', 'users') + op.drop_column('users', 'password_hash') + op.drop_column('users', 'email') + ### end Alembic commands ### \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1434adb59..b7c3da159 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ click==6.7 dominate==2.3.1 Flask==0.12.2 Flask-Bootstrap==3.3.7.1 +Flask-Login==0.4.0 Flask-Mail==0.9.1 Flask-Migrate==2.0.4 Flask-Moment==0.5.1 From 0169aea7745188671eb9c0fb71f3af48b9459dac Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Tue, 18 Jul 2017 07:55:19 -0700 Subject: [PATCH 05/45] Chapter 8: User registration (8d) --- app/auth/forms.py | 26 +++++++++++++++++++++++++- app/auth/views.py | 21 +++++++++++++++++++-- app/templates/auth/login.html | 2 ++ app/templates/auth/register.html | 13 +++++++++++++ 4 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 app/templates/auth/register.html diff --git a/app/auth/forms.py b/app/auth/forms.py index d50cf956b..cfaf89e38 100644 --- a/app/auth/forms.py +++ b/app/auth/forms.py @@ -1,6 +1,8 @@ from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, BooleanField, SubmitField -from wtforms.validators import DataRequired, Length, Email +from wtforms.validators import DataRequired, Length, Email, Regexp, EqualTo +from wtforms import ValidationError +from ..models import User class LoginForm(FlaskForm): @@ -9,3 +11,25 @@ class LoginForm(FlaskForm): password = PasswordField('Password', validators=[DataRequired()]) remember_me = BooleanField('Keep me logged in') submit = SubmitField('Log In') + + +class RegistrationForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Length(1, 64), + Email()]) + username = StringField('Username', validators=[ + DataRequired(), Length(1, 64), + Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0, + 'Usernames must have only letters, numbers, dots or ' + 'underscores')]) + password = PasswordField('Password', validators=[ + DataRequired(), EqualTo('password2', message='Passwords must match.')]) + password2 = PasswordField('Confirm password', validators=[DataRequired()]) + submit = SubmitField('Register') + + def validate_email(self, field): + if User.query.filter_by(email=field.data.lower()).first(): + raise ValidationError('Email already registered.') + + def validate_username(self, field): + if User.query.filter_by(username=field.data).first(): + raise ValidationError('Username already in use.') diff --git a/app/auth/views.py b/app/auth/views.py index 395254359..ecd8ad976 100644 --- a/app/auth/views.py +++ b/app/auth/views.py @@ -1,8 +1,11 @@ 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 .forms import LoginForm +from ..email import send_email +from .forms import LoginForm, RegistrationForm @auth.route('/login', methods=['GET', 'POST']) @@ -26,3 +29,17 @@ def logout(): logout_user() flash('You have been logged out.') return redirect(url_for('main.index')) + + +@auth.route('/register', methods=['GET', 'POST']) +def register(): + form = RegistrationForm() + if form.validate_on_submit(): + user = User(email=form.email.data.lower(), + username=form.username.data, + 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) diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index 476cff57c..1e14c7f5a 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -9,5 +9,7 @@

Login

{{ wtf.quick_form(form) }} +
+

New user? Click here to register.

{% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 000000000..eb14df9e0 --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} + +{% block title %}Flasky - Register{% endblock %} + +{% block page_content %} + +
+ {{ wtf.quick_form(form) }} +
+{% endblock %} From edb7ecb880847f95c30c23de124595946d0f08ab Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Tue, 18 Jul 2017 07:55:20 -0700 Subject: [PATCH 06/45] Chapter 8: Account confirmation (8e) --- app/auth/views.py | 45 ++++++++++++++++++- app/models.py | 19 ++++++++ app/templates/auth/email/confirm.html | 8 ++++ app/templates/auth/email/confirm.txt | 13 ++++++ app/templates/auth/unconfirmed.html | 20 +++++++++ .../190163627111_account_confirmation.py | 26 +++++++++++ .../versions/456a945560f6_login_support.py | 2 +- tests/test_user_model.py | 25 +++++++++++ 8 files changed, 156 insertions(+), 2 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/190163627111_account_confirmation.py diff --git a/app/auth/views.py b/app/auth/views.py index ecd8ad976..9186daa4b 100644 --- a/app/auth/views.py +++ b/app/auth/views.py @@ -8,6 +8,23 @@ from .forms import LoginForm, RegistrationForm +@auth.before_app_request +def before_request(): + if current_user.is_authenticated \ + and not current_user.confirmed \ + and request.endpoint \ + 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() @@ -40,6 +57,32 @@ def register(): password=form.password.data) db.session.add(user) db.session.commit() - flash('You can now login.') + 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('auth.login')) 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 confirmation link 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 729819dbf..6c6030e83 100644 --- a/app/models.py +++ b/app/models.py @@ -1,4 +1,6 @@ from werkzeug.security import generate_password_hash, check_password_hash +from itsdangerous import TimedJSONWebSignatureSerializer as Serializer +from flask import current_app from flask_login import UserMixin from . import db, login_manager @@ -20,6 +22,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 +35,22 @@ def password(self, password): 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: + 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 000000000..e15e221bf --- /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 000000000..16da41df1 --- /dev/null +++ b/app/templates/auth/email/confirm.txt @@ -0,0 +1,13 @@ +Dear {{ user.username }}, + +Welcome to Flasky! + +To confirm your account please click 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 000000000..75bf19a48 --- /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/190163627111_account_confirmation.py b/migrations/versions/190163627111_account_confirmation.py new file mode 100644 index 000000000..7b5457613 --- /dev/null +++ b/migrations/versions/190163627111_account_confirmation.py @@ -0,0 +1,26 @@ +"""account confirmation + +Revision ID: 190163627111 +Revises: 456a945560f6 +Create Date: 2013-12-29 02:58:45.577428 + +""" + +# revision identifiers, used by Alembic. +revision = '190163627111' +down_revision = '456a945560f6' + +from alembic import op +import sqlalchemy as sa + + +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/migrations/versions/456a945560f6_login_support.py b/migrations/versions/456a945560f6_login_support.py index bb75e5097..03afc0670 100644 --- a/migrations/versions/456a945560f6_login_support.py +++ b/migrations/versions/456a945560f6_login_support.py @@ -27,4 +27,4 @@ def downgrade(): op.drop_index('ix_users_email', 'users') op.drop_column('users', 'password_hash') op.drop_column('users', 'email') - ### end Alembic commands ### \ No newline at end of file + ### end Alembic commands ### diff --git a/tests/test_user_model.py b/tests/test_user_model.py index b705a3bcf..4c8765774 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,27 @@ def test_password_salts_are_random(self): 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)) From b7c47012532d24d45282cbcc6e2e84a406460ce0 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Tue, 18 Jul 2017 07:55:22 -0700 Subject: [PATCH 07/45] Chapter 8: Password updates (8f) --- app/auth/forms.py | 9 +++++++++ app/auth/views.py | 18 +++++++++++++++++- app/templates/auth/change_password.html | 13 +++++++++++++ app/templates/base.html | 8 +++++++- 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 app/templates/auth/change_password.html diff --git a/app/auth/forms.py b/app/auth/forms.py index cfaf89e38..582e6a176 100644 --- a/app/auth/forms.py +++ b/app/auth/forms.py @@ -33,3 +33,12 @@ def validate_email(self, field): def validate_username(self, field): if User.query.filter_by(username=field.data).first(): raise ValidationError('Username already in use.') + + +class ChangePasswordForm(FlaskForm): + old_password = PasswordField('Old password', validators=[DataRequired()]) + password = PasswordField('New password', validators=[ + DataRequired(), EqualTo('password2', message='Passwords must match.')]) + password2 = PasswordField('Confirm new password', + validators=[DataRequired()]) + submit = SubmitField('Update Password') diff --git a/app/auth/views.py b/app/auth/views.py index 9186daa4b..2f9cfd06c 100644 --- a/app/auth/views.py +++ b/app/auth/views.py @@ -5,7 +5,7 @@ from .. import db from ..models import User from ..email import send_email -from .forms import LoginForm, RegistrationForm +from .forms import LoginForm, RegistrationForm, ChangePasswordForm @auth.before_app_request @@ -86,3 +86,19 @@ def resend_confirmation(): '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')) + + +@auth.route('/change-password', methods=['GET', 'POST']) +@login_required +def change_password(): + form = ChangePasswordForm() + if form.validate_on_submit(): + if current_user.verify_password(form.old_password.data): + current_user.password = form.password.data + db.session.add(current_user) + db.session.commit() + flash('Your password has been updated.') + return redirect(url_for('main.index')) + else: + flash('Invalid password.') + return render_template("auth/change_password.html", form=form) diff --git a/app/templates/auth/change_password.html b/app/templates/auth/change_password.html new file mode 100644 index 000000000..374d86206 --- /dev/null +++ b/app/templates/auth/change_password.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} + +{% block title %}Flasky - Change Password{% endblock %} + +{% block page_content %} + +
+ {{ wtf.quick_form(form) }} +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index bc2c94fe2..cd96c3338 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -26,7 +26,13 @@