實作部落格程式 part 1

Author: 毛毛, Alicia

Outline

  • Ch8: User Authentication

Ch8: User Authentication

Password Security

Store user passwords securely in a DB relies not on storing the password itself but a hash of it

  • Most users use the same password on multiple sites
    • If attacker got the plain password in your DB means he can access to other sites by your account

Hash

  • A hash value (or hash) is a number or string generated from a hash function that takes in input values and outputs another string or number.
  • The hash value is usually generated by a formula that is extremely hard for others to reproduce the same hash value.
  • Hashes is very important in security systems.

Hashing Passwords with Werkzeug

  • Werkzeug’s security module conveniently implements secure password hashing
    • generate_password_hash(password, method="pbkdf2:sha256", salt_length=8)
      • When to use: user registration
    • check_password_hash(hash, password)
      • When to use: user verification
      • A return value of True indicates that the password is correct

Test hashing function in shell

(venv) $ python
>>> from werkzeug.security import generate_password_hash, check_password_hash
>>> password="123"
>>> password_hash=generate_password_hash(password, method="pbkdf2:sha1")
>>> password_hash
'pbkdf2:sha1:50000$iO1WliCS$19c136df89c10115bf1e983e57c060f9345452c3
>>> check_password_hash(password_hash, password)
True
>>> check_password_hash(password_hash, "12")
False

Password hashing in User model

In [ ]:
# app/models.py

from werkzeug.security import generate_password_hash, check_password_hash

...

class User(db.Model):
    ...
    email = db.Column(db.String(64), unique=True, index=True)
    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, method="pbkdf2:sha1")

    def verify_password(self, password):
        return check_password_hash(self.password_hash, password)
   
    ...

Note

Test User model in shell

(venv) $ python manage.py shell
>>> u = User()
>>> u.password = 'cat'
>>> u.verify_password('cat')
True
>>> u.verify_password('dog')
False
>>> u2 = User()
>>> u2.password = 'cat'
>>> u.password_hash == u2.password_hash
False
>>> u.password_hash
'pbkdf2:sha1:50000$4DzKb2yV$f1e49b38a34d0726310016c59fcc88f509e40ce8'
>>> u2.password_hash
'pbkdf2:sha1:50000$cXR2Idzh$ecc84c8665d2646b78487ca34c9f7da32ae03c60'

Note

  • Users u and u2 have different password hashes, even though they both use the same password

Create database migration script because model was changed

(venv) $ python manage.py db migrate -m "Add new column in 'users'"

Note:

  • If alembic.util.exc.CommandError: Target database is not up to date occurs, please do python manage.py db upgrade first

Write new unit tests to ensure this functionality continues to work in the future

In [ ]:
# tests/test_user_model.py

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()

    ...
In [ ]:
# tests/test_user_model.py

...

class UserModelTestCase(unittest.TestCase):
    ...
    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)

You can get the above code from github

Remember to upgrade your DB

  • $ python manage.py db upgrade

Creating an Authentication Blueprint

  • The routes related to the user authentication system can be added to a auth blueprint

Using different blueprints for different sets of application functionality is a great way to keep the code neatly organized

In [ ]:
# app/auth/__init__.py

from flask import Blueprint

auth = Blueprint('auth', __name__)

from . import views
In [ ]:
# app/auth/views.py

from flask import render_template

from . import auth

@auth.route('/login')
def login():
    return render_template('auth/login.html')
In [ ]:
<!-- app/templates/auth/login.html -->

{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Login</h1>
</div>
{% endblock %}
In [ ]:
# app/__init__.py

...

def create_app(config_name):
    ...
    
    # blueprint registration
    ...
    from .auth import auth as auth_blueprint
    app.register_blueprint(auth_blueprint, url_prefix='/auth')
    
    ...

url_prefix

  • This argument in the blueprint registration is optional
  • When used, all the routes defined in the blueprint will be registered with the given prefix

You can get the above code from github

User Authentication with Flask-Login

  • When users log in to the application, their authenticated state has to be recorded
    • As they navigate through different pages, they don't need to log in again

Installation

  • pip install flask-login

Preparing the User Model for Logins

  • The Flask-Login extension requires a few methods to be implemented
Method Description
is_authenticated() Must return True if the user has login credentials or False otherwise.
is_active() Must return True if the user is allowed to log in or False otherwise. A False return value can be used for disabled accounts.
is_anonymous() Must always return False for regular users
get_id() Must return a unique identifier for the user, encoded as a Unicode string

These four methods can be implemented directly as methods in the model class

  • But as an easier alternative Flask-Login provides a UserMixin class
    • Has default implementations that are appropriate for most cases
In [ ]:
# app/models.py

from flask_login import UserMixin
...

class User(UserMixin, db.Model):
    ...

Flask-Login initialization

In [ ]:
# app/__init__.py

...
from flask_login import LoginManager

...
login_manager = LoginManager()
login_manager.session_protection = 'strong'
login_manager.login_view = 'auth.login'

def create_app(config_name):
    ...
    login_manager.init_app(app)

    # blueprint registration
    ...

session_protection

  • Can be set to None, 'basic', or 'strong' to provide different levels of security against user session tampering
  • With the 'strong' setting, Flask-Login will keep track of the client’s IP address and browser agent and will log the user out if it detects a change

login_view

  • Set the endpoint for the login page

Prepare the user loader callback function for Flask-Login

  • This function will receive a user identifier as a Unicode string
  • The return value of this function must be the user object if available or None otherwise
In [ ]:
# app/models.py

...
from . import login_manager

@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

...

Protecting Routes

  • A route can only be accessed by authenticated users
  • Flask-Login provides a login_required decorator
In [ ]:
# app/main/views.py

...
from flask_login import login_required

...

@main.route('/secret')
@login_required
def secret():
    return 'Only authenticated users are allowed!'

If this route is accessed by a user who is not authenticated, Flask-Login will intercept the request and send the user to the login page instead.

When user wants to access "http://127.0.0.1:5000/secret", he or she will be redirected to login page

You can get the above code from github

Adding a Login Form

In [ ]:
# app/auth/forms.py

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import Required, Length, Email


class LoginForm(FlaskForm):
    email = StringField('Email', validators=[Required(), Length(1, 64), Email()])
    password = PasswordField('Password', validators=[Required()])
    remember_me = BooleanField('Keep me logged in')
    submit = SubmitField('Log In')

Routing: login

In [ ]:
# app/auth/views.py

from flask import render_template, redirect, request, url_for, flash
from flask_login import login_user

from . import auth
from ..models import User
from .forms import LoginForm


@auth.route('/login', methods=['GET', 'POST'])
def login():
    form = LoginForm()
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data).first()
        if user is not None and user.verify_password(form.password.data):
            login_user(user, form.remember_me.data)
            return redirect(request.args.get('next') or url_for('main.index'))

        flash('Invalid username or password.')

    return render_template('auth/login.html', form=form)
In [ ]:
<!-- app/templates/auth/login.html -->

{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky{% endblock %}

{% block content %}
<div class="container">
    {% for message in get_flashed_messages() %}
    <div class="alert alert-warning">
        <button type="button" class="close" data-dismiss="alert">&times;</button>
        {{ message }}
    </div>
    {% endfor %}

    {% block page_content %}
    <div class="page-header">
        <h1>Login</h1>
    </div>
    {{ wtf.quick_form(form) }}
    {% endblock %}
</div>
{% endblock %}

login_user

  • To record the user as logged in for the user session
  • This function takes the user to log in and an optional “remember me” Boolean
    • A value of False for “remember me” causes the user session to expire when the browser window is closed
    • A value of True for “remember me” causes a long-term cookie to be set in the user’s browser

There are two possible URL destinations for a redirection

  • The home page
    • If the 'next' query string is not available
  • The protected page
    • Is allowed be accessed after a user log in
    • Flask-Login will save this URL in the 'next' query string when non-login user accesses this page

Show the current user

In [ ]:
# app/main/views.py

from flask import render_template, session, redirect, url_for, current_app
from flask_login import login_required, current_user

from .. import db
from ..models import User
from ..email import send_email
from . import main
from .forms import NameForm


@main.route('/')
def index():
    return render_template('index.html')


@main.route('/secret')
@login_required
def secret():
    return 'Only authenticated users are allowed! Current user: {}'.format(current_user.username)
In [ ]:
<!-- app/templates/index.html -->

{% extends "base.html" %}

{% block title %}Flasky{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Hello, {% if current_user.is_authenticated %}{{ current_user.username }}{% else %}Stranger{% endif %}!</h1>
</div>
{% endblock %}

current_user

  • Is defined by Flask-Login and is automatically available to view functions and templates
  • Contains the user currently logged in, or a proxy anonymous user object if the user is not logged in
    • Anonymous user objects respond to the is_authenticated() method with False

Prepare a user for testing

(venv) $ python manage.py shell
>>> user = User(username="Maomao", email="maomao@tw.pyladies.com")
>>> user.password = "test"
>>> db.session.add(user)
>>> db.session.commit()

Before typing in the correct email and password

After typing in the correct email and password

Type in the wrong email or password

Watch Out

  • On a production server, the login route must be made available over secure HTTP so that the form data transmitted to the server is encrypted.
  • Without secure HTTP, the login credentials can be intercepted during transit, defeating any efforts put into securing passwords in the server.

Routing: logout

In [ ]:
# app/auth/views.py

...
from flask_login import login_user, logout_user, login_required

...

@auth.route('/logout')
@login_required
def logout():
    logout_user()
    flash('You have been logged out.')
    return redirect(url_for('main.index'))

logout_user

  • To remove and reset the user session

After login-user accessed to "http://127.0.0.1:5000/auth/logout"

In [ ]:
<!-- app/templates/base.html -->

{% extends "bootstrap/base.html" %}

{% block title %}Flasky{% endblock %}

{% block head %}
{{ super() }}
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
{% endblock %}

...
In [ ]:
...

{% block navbar %}
<div class="navbar navbar-inverse" role="navigation">
    <div class="container">
        <div class="navbar-header">
            <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
                <span class="sr-only">Toggle navigation</span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
            </button>
            <a class="navbar-brand" href="{{ url_for('main.index') }}">Flasky</a>
        </div>
        <div class="navbar-collapse collapse">
            <ul class="nav navbar-nav">
                <li><a href="{{ url_for('main.index') }}">Home</a></li>
            </ul>
            <ul class="nav navbar-nav navbar-right">
                {% if current_user.is_authenticated %}
                <li><a href="{{ url_for('auth.logout') }}">Log Out</a></li>
                {% else %}
                <li><a href="{{ url_for('auth.login') }}">Log In</a></li>
                {% endif %}
            </ul>
        </div>
    </div>
</div>
{% endblock %}

...
In [ ]:
...

{% block content %}
<div class="container">
    {% for message in get_flashed_messages() %}
    <div class="alert alert-warning">
        <button type="button" class="close" data-dismiss="alert">&times;</button>
        {{ message }}
    </div>
    {% endfor %}

    {% block page_content %}{% endblock %}
</div>
{% endblock %}

{% block scripts %}
{{ super() }}
{{ moment.include_moment() }}
{% endblock %}

Before login

After login

After logout

You can get the above code from github

New User Registration

Adding a User Registration Form

In [ ]:
# app/auth/forms.py

...
from wtforms.validators import Required, Length, Email, Regexp, EqualTo
from wtforms import ValidationError

from ..models import User


class LoginForm(FlaskForm):
    ...


class RegistrationForm(FlaskForm):
    email = StringField('Email', validators=[Required(), Length(1, 64),
    Email()])
    username = StringField('Username', validators=[Required(), 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=[Required(), EqualTo('password2', message='Passwords must match.')])
    password2 = PasswordField('Confirm password', validators=[Required()])
    submit = SubmitField('Register')

    def validate_email(self, field):
        if User.query.filter_by(email=field.data).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.')

The password is entered twice as a safety measure

  • Validate the two password fields have the same content
    • Use EqualTo
      • This validator is attached to one of the password fields
      • The name of the other field given as an argument

Custom validator

  • A method with the prefix validate_ followed by the name of a field
    • validate_email()
    • validate_username()
  • Is invoked in addition to any regularly defined validators

Registering New Users

In [ ]:
# app/auth/views.py

...
from .forms import LoginForm, RegistrationForm
from .. import db

...

@auth.route('/register', methods=['GET', 'POST'])
def register():
    form = RegistrationForm()
    if form.validate_on_submit():
        user = User(email=form.email.data, username=form.username.data, password=form.password.data)
        db.session.add(user)
        flash('You can now login.')
        return redirect(url_for('auth.login'))
    return render_template('auth/register.html', form=form)
In [ ]:
<!-- app/templates/auth/register.html -->

{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - Register{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Register</h1>
</div>
<div class="col-md-4">
    {{ wtf.quick_form(form) }}
</div>
{% endblock %}
In [ ]:
<!-- app/templates/auth/login.html -->

{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - Login{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Login</h1>
</div>
<div class="col-md-4">
    {{ wtf.quick_form(form) }}
    <br>
    <p>New user? <a href="{{ url_for('auth.register') }}">Click here to register</a>.</p>
</div>
{% endblock %}

You can get the above code from github

Account Confirmation

To validate the email address, applications send a confirmation email to users immediately after they register

  • The new account is initially marked as unconfirmed until the instructions in the email are followed
    • Usually click a specially crafted URL link that includes a confirmation token

Generating Confirmation Tokens with itsdangerous

In Chapter 4, Flask uses cryptographically signed cookies to protect the content of user sessions against tampering

  • These secure cookies are signed by a package called itsdangerous
    • The same idea can be applied to confirmation tokens

How itsdangerous can generate a secure token that contains a user id inside

(venv) $ python manage.py shell
>>> from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
>>> s = Serializer(app.config['SECRET_KEY'], expires_in = 3600)
>>> token = s.dumps({'confirm': 23})
>>> token
b'eyJhbGciOiJIUzI1NiIsImlhdCI6MTUxMTYxNDA4OSwiZXhwIjoxNTExNjE3Njg5fQ.eyJjb25maXJtIjoyM30.P5qXhbnswX3LwMalPj-RBnOj3WmnsDJwT_TzN7CtVlw'
>>> data = s.loads(token)
>>> data
{'confirm': 23}

TimedJSONWebSignatureSerializer

  • Generates JSON Web Signatures (JWS) with a time expiration
    • expires_in argument sets an expiration time for the token expressed in seconds
  • The constructor of this class takes an encryption key as argument
    • In a Flask application can be the configured SECRET_KEY

dumps()

  • Generates a cryptographic signature for the data given as an argument
  • Serializes the data plus the signature as a convenient token string

loads()

  • Verifies the signature and the expiration time
    • Valid: returns the original data
    • Invalid or expired: throws an exception

Add token generation and verification to the User model

In [ ]:
# app/models.py

...
from flask import current_app
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer

...

class User(UserMixin, db.Model):
    ...
    confirmed = db.Column(db.Boolean, default=False)
    
    def generate_confirmation_token(self, expiration=3600):
        s = Serializer(current_app.config['SECRET_KEY'], expiration)
        return s.dumps({'confirm': self.id})

    def confirm(self, token):
        s = Serializer(current_app.config['SECRET_KEY'])
        try:
            data = s.loads(token)
        except:
            return False

        if data.get('confirm') != self.id:
            return False

        self.confirmed = True
        db.session.add(self)
        return True
    
    ...

Send Confirmation Emails

In [ ]:
# app/auth/views.py

...
from ..email import send_email

...

@auth.route('/register', methods=['GET', 'POST'])
def register():
    form = RegistrationForm()
    if form.validate_on_submit():
        user = User(email=form.email.data, username=form.username.data, password=form.password.data)
        db.session.add(user)
        db.session.commit()

        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)

Note

  • A db.session.commit() call had to be added even though the application configured automatic database commits at the end of the request
    • New users get assigned an id when they are committed to the database
    • The id is needed for the confirmation token, the commit cannot be delayed

app/templates/auth/email/confirm.txt

In [ ]:
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.
In [ ]:
<!-- app/templates/auth/email/confirm.html -->

<p>Dear {{ user.username }},</p>
<p>Welcome to <b>Flasky</b>!</p>
<p>To confirm your account please <a href="{{ url_for('auth.confirm', token=token, _external=True) }}">click here</a>.</p>
<p>Alternatively, you can paste the following link in your browser's address bar:</p>
<p>{{ url_for('auth.confirm', token=token, _external=True) }}</p>
<p>Sincerely,</p>
<p>The Flasky Team</p>
<p><small>Note: replies to this email address are not monitored.</small></p>

Prepare Confirmation URL

In [ ]:
# app/auth/views.py

...
from flask_login import current_user

...

@auth.route('/confirm/<token>')
@login_required
def confirm(token):
    if current_user.confirmed:
        return redirect(url_for('main.index'))

    if current_user.confirm(token):
        flash('You have confirmed your account. Thanks!')
    else:
        flash('The confirmation link is invalid or has expired.')
    return redirect(url_for('main.index'))

Create database migration script because model was changed

(venv) $ python manage.py db migrate -m "Add 'confirmed' column in 'users'"

Note:

  • If alembic.util.exc.CommandError: Target database is not up to date occurs, please do python manage.py db upgrade first

Upgrade your DB

  • $ python manage.py db upgrade

After registeration finished

Note

Write new unit tests to ensure this functionality continues to work in the future

In [ ]:
# tests/test_user_model.py

import time
...

class UserModelTestCase(unittest.TestCase):
    ...
    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))

You can get the above code from github

Remember to upgrade your DB

  • $ python manage.py db upgrade

What unconfirmed users are allowed to do before they confirm their account?

  • Allow unconfirmed users to log in, but only show them a page that asks them to confirm their accounts before they can gain access
    • This step can be done using Flask’s before_request hook, which was briefly described in Chapter 2

before_request vs. before_app_request

  • before_request: a hook for requests belong to the blueprint
  • before_app_request: a hook for all application requests
In [ ]:
# app/auth/views.py

...

@auth.before_app_request
def before_request():
    if current_user.is_authenticated \
            and not current_user.confirmed \
            and request.endpoint \
            and request.endpoint[:5] != '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')
In [ ]:
<!-- app/templates/auth/unconfirmed.html -->

{% extends "base.html" %}

{% block title %}Flasky - Confirm your account{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>
        Hello, {{ current_user.username }}!
    </h1>
    <h3>You have not confirmed your account yet.</h3>
    <p>
        Before you can access this site you need to confirm your account.
        Check your inbox, you should have received an email with a confirmation link.
    </p>
    <p>
        Need another confirmation email?
        <a href="{{ url_for('auth.resend_confirmation') }}">Click here</a>
    </p>
</div>
{% endblock %}
In [ ]:
# app/auth/views.py

...

@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'))

After pressing the "Click here"

You can get the above code from github

Account Management

Coding Time

  • Password updates
  • Password resets
  • Email address changes

[Password updates]

Expected Results

Type wrong old password

Steps

  • Modify app/templates/base.html

Replace

In [ ]:
<li><a href="{{ url_for('auth.logout') }}">Log Out</a></li>

With

In [ ]:
<li class="dropdown">
    <a href="#" class="dropdown-toggle" data-toggle="dropdown">Account <b class="caret"></b></a>
    <ul class="dropdown-menu">
        <li><a href="{{ url_for('auth.change_password') }}">Change Password</a></li>
        <li><a href="{{ url_for('auth.logout') }}">Log Out</a></li>
    </ul>
</li>

2. Add a new form: "ChangePasswordForm"

  • Modify app/auth/forms.py
In [ ]:
# app/auth/forms.py

...

class ChangePasswordForm(FlaskForm):
    old_password = PasswordField('Old password', validators=[Required()])
    password = PasswordField('New password', validators=[Required(), EqualTo('password2', message='Passwords must match.')])
    password2 = PasswordField('Confirm new password', validators=[Required()])
    submit = SubmitField('Update Password')

3. Add an new template: "change_password.html"

  • Create app/templates/auth/change_password.html
In [ ]:
<!-- app/templates/auth/change_password.html -->

{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - Change Password{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Change Your Password</h1>
</div>
<div class="col-md-4">
    {{ wtf.quick_form(form) }}
</div>
{% endblock %}

4. Add a new routing: "/change-password"

  • Modify app/auth/views.py
In [ ]:
# app/auth/views.py

...

@auth.route('/change-password', methods=['GET', 'POST'])
@login_required
def change_password():
    # TODO: complete this function by yourself
    pass

You can get the above code from github

[Password resets]

Expected Results

Steps

  • Modify app/templates/auth/login.html
In [ ]:
<p>Forgot your password? <a href="{{ url_for('auth.password_reset_request') }}">Click here to reset it</a>.</p>

2. Add a new form: "PasswordResetRequestForm"

  • Modify app/auth/forms.py
In [ ]:
# app/auth/forms.py

...

class PasswordResetRequestForm(FlaskForm):
    email = StringField('Email', validators=[Required(), Length(1, 64), Email()])
    submit = SubmitField('Reset Password')

3. Add an new template: "reset_password.html"

  • Create app/templates/auth/reset_password.html
In [ ]:
<!-- app/templates/auth/reset_password.html -->

{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - Password Reset{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Reset Your Password</h1>
</div>
<div class="col-md-4">
    {{ wtf.quick_form(form) }}
</div>
{% endblock %}

4. Add new templates for email: "reset_password.txt" and "reset_password.html"

  • Create app/templates/auth/email/reset_password.txt
  • Create app/templates/auth/email/reset_password.html

app/templates/auth/email/reset_password.txt

In [ ]:
Dear {{ user.username }},

To reset your password click on the following link:

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

If you have not requested a password reset simply ignore this message.

Sincerely,

The Flasky Team

Note: replies to this email address are not monitored.
In [ ]:
<!-- app/templates/auth/email/reset_password.html -->

<p>Dear {{ user.username }},</p>
<p>To reset your password <a href="{{ url_for('auth.password_reset', token=token, _external=True) }}">click here</a>.</p>
<p>Alternatively, you can paste the following link in your browser's address bar:</p>
<p>{{ url_for('auth.password_reset', token=token, _external=True) }}</p>
<p>If you have not requested a password reset simply ignore this message.</p>
<p>Sincerely,</p>
<p>The Flasky Team</p>
<p><small>Note: replies to this email address are not monitored.</small></p>

5. Add token generation and verification to the User model for resetting password

  • Modify app/models.py
In [ ]:
# app/models.py

...

class User(UserMixin, db.Model):
    ...
    def generate_reset_token(self, expiration=3600):
        s = Serializer(current_app.config['SECRET_KEY'], expiration)
        return s.dumps({'reset': self.id}).decode('utf-8')

    @staticmethod
    def reset_password(token, new_password):
        s = Serializer(current_app.config['SECRET_KEY'])
        try:
            data = s.loads(token.encode('utf-8'))
        except:
            return False

        user = User.query.get(data.get('reset'))
        if user is None:
            return False

        user.password = new_password
        db.session.add(user)
        return True
    
    ...

6. Add a new routing: "/reset"

  • Modify app/auth/views.py
In [ ]:
# app/auth/views.py

...

@auth.route('/reset', methods=['GET', 'POST'])
def password_reset_request():
    # TODO: complete this function by yourself
    pass

7. Add a new form: "PasswordResetForm"

  • Modify app/auth/forms.py
In [ ]:
# app/auth/forms.py

...

class PasswordResetForm(FlaskForm):
    password = PasswordField('New Password', validators=[Required(), EqualTo('password2', message='Passwords must match')])
    password2 = PasswordField('Confirm password', validators=[Required()])
    submit = SubmitField('Reset Password')

8. Add a new routing: "/reset/<token>"

  • Modify app/auth/views.py
  • Reuse the old template "reset_password.html" for "PasswordResetForm"
In [ ]:
# app/auth/views.py

...

@auth.route('/reset/<token>', methods=['GET', 'POST'])
def password_reset(token):
    # TODO: complete this function by yourself
    pass

Tests

In [ ]:
# tests/test_user_model.py

...

class UserModelTestCase(unittest.TestCase):
    ...
    def test_valid_reset_token(self):
        u = User(password='cat')
        db.session.add(u)
        db.session.commit()
        token = u.generate_reset_token()
        self.assertTrue(User.reset_password(token, 'dog'))
        self.assertTrue(u.verify_password('dog'))

    def test_invalid_reset_token(self):
        u = User(password='cat')
        db.session.add(u)
        db.session.commit()
        token = u.generate_reset_token()
        self.assertFalse(User.reset_password(token + 'a', 'horse'))
        self.assertTrue(u.verify_password('cat'))

You can get the above code from github

[Email address changes]

Expected Results

Steps

  • Modify app/templates/base.html
In [ ]:
<li><a href="{{ url_for('auth.change_email_request') }}">Change Email</a></li>

2. Add a new form: "ChangeEmailForm"

  • Modify app/auth/forms.py
In [ ]:
# app/auth/forms.py

...

class ChangeEmailForm(FlaskForm):
    email = StringField('New Email', validators=[Required(), Length(1, 64), Email()])
    password = PasswordField('Password', validators=[Required()])
    submit = SubmitField('Update Email Address')

    def validate_email(self, field):
        if User.query.filter_by(email=field.data).first():
            raise ValidationError('Email already registered.')

3. Add an new template: "change_email.html"

  • Create app/templates/auth/change_email.html
In [ ]:
<!-- app/templates/auth/change_email.html -->

{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - Change Email Address{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Change Your Email Address</h1>
</div>
<div class="col-md-4">
    {{ wtf.quick_form(form) }}
</div>
{% endblock %}

4. Add new templates for email: "change_email.txt" and "change_email.html"

  • Create app/templates/auth/email/change_email.txt
  • Create app/templates/auth/email/change_email.html

app/templates/auth/email/change_email.txt

In [ ]:
Dear {{ user.username }},

To confirm your new email address click on the following link:

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

Sincerely,

The Flasky Team

Note: replies to this email address are not monitored.
In [ ]:
<!-- app/templates/auth/email/change_email.html -->

<p>Dear {{ user.username }},</p>
<p>To confirm your new email address <a href="{{ url_for('auth.change_email', token=token, _external=True) }}">click here</a>.</p>
<p>Alternatively, you can paste the following link in your browser's address bar:</p>
<p>{{ url_for('auth.change_email', token=token, _external=True) }}</p>
<p>Sincerely,</p>
<p>The Flasky Team</p>
<p><small>Note: replies to this email address are not monitored.</small></p>

5. Add token generation and verification to the User model for changing email

  • Modify app/models.py
In [ ]:
# app/models.py

...

class User(UserMixin, db.Model):
    ...
    def generate_email_change_token(self, new_email, expiration=3600):
        s = Serializer(current_app.config['SECRET_KEY'], expiration)
        return s.dumps(
            {'change_email': self.id, 'new_email': new_email}).decode('utf-8')

    def change_email(self, token):
        s = Serializer(current_app.config['SECRET_KEY'])
        try:
            data = s.loads(token.encode('utf-8'))
        except:
            return False

        if data.get('change_email') != self.id:
            return False
        new_email = data.get('new_email')
        if new_email is None:
            return False
        if self.query.filter_by(email=new_email).first() is not None:
            return False

        self.email = new_email
        db.session.add(self)
        return True

6. Add a new routing: "/change_email"

  • Modify app/auth/views.py
In [ ]:
# app/auth/views.py

...

@auth.route('/change_email', methods=['GET', 'POST'])
@login_required
def change_email_request():
    # TODO: complete this function by yourself
    pass

7. Add a new routing: "/change_email/<token>"

  • Modify app/auth/views.py
In [ ]:
# app/auth/views.py

...

@auth.route('/change_email/<token>')
@login_required
def change_email(token):
    # TODO: complete this function by yourself
    pass

Tests

In [ ]:
# tests/test_user_model.py

class UserModelTestCase(unittest.TestCase):
    ...    
    def test_valid_email_change_token(self):
        u = User(email='john@example.com', password='cat')
        db.session.add(u)
        db.session.commit()
        token = u.generate_email_change_token('susan@example.org')
        self.assertTrue(u.change_email(token))
        self.assertTrue(u.email == 'susan@example.org')

    def test_invalid_email_change_token(self):
        u1 = User(email='john@example.com', password='cat')
        u2 = User(email='susan@example.org', password='dog')
        db.session.add(u1)
        db.session.add(u2)
        db.session.commit()
        token = u1.generate_email_change_token('david@example.net')
        self.assertFalse(u2.change_email(token))
        self.assertTrue(u2.email == 'susan@example.org')

    def test_duplicate_email_change_token(self):
        u1 = User(email='john@example.com', password='cat')
        u2 = User(email='susan@example.org', password='dog')
        db.session.add(u1)
        db.session.add(u2)
        db.session.commit()
        token = u2.generate_email_change_token('john@example.com')
        self.assertFalse(u2.change_email(token))
        self.assertTrue(u2.email == 'susan@example.org')

You can get the above code from github