Registration, authentication, and email confirmation

So now we have a functioning web form that sends data to DynamoDB, but no access restrictions! We need a way to ensure that authors can only edit their own biographies. To do this, we'll use a Flask extension called Flask-Login. The extension handles user session management (logging in, logging out, and remembering users).

It's important to note that sessions only work securely over HTTPS connections. At a high level, the way Flask sessions work is:

  1. The server sets a cryptographically signed cookie with a session variable on the client browser
  2. When the client moves to another page, the cookie data is sent back to the server
  3. The server matches the cookie data to a list of known sessions, and doesn't require re-authentication if the session is known (and not expired)

This doesn't work without secure HTTP, because without a secure connection the cookie can be intercepted and used by a third party until it expires. Fortunately AWS API gateway functions use HTTPS natively. At the end of this book, we'll also be getting an SSL certificate for use with custom domains.

Flask-Login

To actually use Flask-Login, we do the following:

from flask_login import LoginManager, login_required, login_user, logout_user, current_user, UserMixin

login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'login'

@login_manager.user_loader
def load_user(user_id):
    try:
        return User(user_id)
    except:
        return None

Above is the basic setup for Flask-Login. We import a bunch of functions that we'll need, then we instantiate a LoginManager object called login_manager. We then pass app to the init_app function of login_manager — the function init_app just allows Flask-Login to tell our Flask instance (app) to use it for log-in functionality. After init, we set login_view to point to login, which is a view function that returns the html for our login form — we'll write that later. Whenever any other view function is invoked that has a @login_required decorator attached and the user is not logged in, the function pointed to by login_view is called. For instance, if we tried to get to the change password page on our site, we'd want to protect that view function with an @login_required decorator. Then if we tried to visit that page without logging in, we'd be redirected to the login page. Finally, we use the user_loader decorator to tell Flask-Login that load_user is the callback function that's used to reload the user object from the user ID stored in the session. This means that when we log in and set our session cookie on the client side like we discussed above, we can get back our user information on subsequent server requests. You can see there that there's a class called User that we'll be instantiating later on.

Login function

Now that we've set login_manager.login_view to the function login, we should actually write the function.

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        userid = request.form['author_email']
        password = request.form['author_password']
        if "remember_me" in request.form.keys():
            remember_me = True
        else:
            remember_me = False
        user = User(userid)
        if user.verify_password(password):
            login_user(user, remember_me)
            return redirect(url_for('index'))

    return render_template('login.html')

As always, the function has different behaviors depending on whether the request is a GET or a POST. Here we assume we have a login.html file (available in the git repo here). The login page contains a form with an author_email field, an author_password field, and a remember_me checkbox.

Logging out is very easy, you just need a new route that calls flask_login.logout_user (there's an example of this in the profile-app.py file).

User Class

Flask-Login makes it a little easier for us to build our User class by providing a mixin called UserMixin. This class has default implementations for a handful of properties and methods that are required by user classes that are loaded by our user_loader function:

  • is_authenticated This property should return True if the user is authenticated, i.e. they have provided valid credentials.
  • is_active This property should return True if this is an active user - in addition to being authenticated, they also have activated their account, not been suspended, or any condition your application has for rejecting an account.
  • is_anonymous This property should return True if this is an anonymous user. (Actual users should return False instead.)
  • get_id() This method must return a unicode that uniquely identifies this user, and can be used to load the user from the user_loader callback. Note that this must be a unicode string - if the ID is natively an int or some other type, you will need to convert it to unicode.

All of the above properties and methods are already taken care of if your User class inherits from UserMixin like this:

class User(UserMixin):

    def __init__(self, userid):
        self.dynamodb = boto3.resource('dynamodb')
        self.table = dynamodb.Table('your_dynamo_table')
        self.id = userid
        item = table.get_item(Key={'author_email': userid})
        self.username = item['Item']['username']
        self.password_hash = item['Item']['password_hash']
        self.confirmed = item['Item']['email_confirmed']

Here we've only implemented the initializer — there are several more functions to write for a complete user class, but the class above has everything needed for use with Flask-Login. The fact that it inherits from UserMixin gives it the proper properties and methods. You can see that self.id gets set to the ID passed to the initializer; this is the value that the inherited get_id() method will return (cast to a unicode string, of course).

The only other thing that we do in the initializer above is to use the user ID to get the full set of profile information from DynamoDB.

Registration and confirmation

So now that we have a user class, what do we do with it? There are only three places we'll be instantiating the User class in the app:

  • The login function, as shown above,
  • The load_user function, also discussed above in the Flask-Login section, that we've decorated with the @login_manager.user_loader decorator. This function will be called whenever we already have a logged-in session in place.
  • The register function, which will create a new user and then instantiate the User class to log the user in.

Here's what our register function will look like:

@app.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'POST':
        userid = request.form['author_email']
        username = request.form['author_name']
        userpassword = request.form['author_password']
        if not new_user(userid,username,userpassword):
            return render_template('notification.html', notification="That email address is already in use.")
        user = User(userid, username)
        token = user.generate_confirmation_token()
        user.email('email/confirmation', 'confirm your email address', user=user, token=token)
        login_user(user)
        return redirect(url_for('index'))
    else:
        return render_template('register.html')

The conditional statement checking to see whether the client is attempting a POST should be familiar by now. Basically all we're doing here is getting an email, name, and password back from the registration form and then trying to create a new user (to see the full new_user function, go to the Source code section and search for new_user). If we're successful, then we send a confirmation token to the user's email address to validate the address. You can obviously put whatever you want in the confirmation email, but at minimum you'll need a link to the confirmation page, which is created by this view function:

@app.route('/confirm/<token>')
@login_required
def confirm(token):
    if current_user.confirmed:
        return redirect(url_for('index'))
    if current_user.confirm(token):
        return redirect(url_for('index'))
    else:
        return render_template('notification.html', notification="The confirmation link is either invalid or expired.")

Here three things should be apparent:

  1. This is the first function that we've created that accepts a parameter — the parameter is passed through the URL, as denoted in the route decorator.
  2. We need to add a confirm function to our User class, since we call it here. The confirm function should set the confirmed variable in the User object to true, and also save that information to the database.
  3. There's a login_required decorator on the confirm function. This leads us nicely into the next section: protecting routes.

Protecting routes

To make sure an unknown client can't invoke a view function that's login protected, all we need to do is decorate the view function with @login_required. If there's no current login session, the server will reroute the client to the view function specified by login_manager.login_view — in this case, we've set this to the function login.

Just using @login_required is the simplest case, but there are times when we'll want to have multiple layers of confirmation. For instance, perhaps we want to prevent access to pages until a user's email address is confirmed. We can even go beyond that, and say that we want to only allow users to access the site if they have both their email confirmed and a manual confirmation done by the publisher:

@app.before_request
def before_request():
    if current_user.is_authenticated and 'confirm' not in request.url_rule.rule:
        if not current_user.confirmed:
            return render_template('notification.html', notification="You must confirm your email address by clicking the link sent to you via email.")
        if not current_user.publisher_confirmed:
            return render_template('notification.html', notification="You must wait to have your account approved by the publisher.")

You can see here that if the user is not confirmed, we only let the user hit the confirm endpoint, and notify them otherwise. If the user is confirmed, but hasn't been approved, we inform the user of that fact.


If you're finding this guide useful, you may want to sign up to receive more of my writing at cloudconsultant.dev.