16
Building a SaaS App (Part IV): User Authentication in Flask and React
Once you've finished this post, you'll have a secure Flask application that handles the user login and sign-up process. As a bonus, we'll tackle not only traditional sign-up, but Google OAuth as well. We'll also introduce React to the series, and incorporate the concept of protected routes into the app.
If you haven't read the first post in the series, this is a step by step guide on building a SaaS app that goes beyond the basics, showing you how to do everything from accept payments to manage users. The example project is a Google rank tracker that we'll build together piece by piece, but you can apply these lessons to any kind of SaaS app.
In the last post, we introduced SQLAlchemy and covered some of the performance pitfalls that you should be aware of. We're going to cover a lot of ground in this post, including authentication on the back-end using Flask, but also how to protect pages that require login using React.
We'll use JWTs to authenticate requests to the Open Rank Tracker API. JSON Web Tokens are, as the name implies, a JSON payload that resides either in a cookie or in local storage on the browser. The token is sent to the server with every API request, and contains at least a user ID or other identifying piece of information.
Given that we shouldn't blindly trust data coming from the front-end, how can we trust what's inside a JWT? How do we know someone hasn't changed the user ID inside the token to impersonate another user?
JWTs work because they are given a cryptographic signature using a secret that only resides on the back-end. This signature is verified with every request, and if the contents of the token are altered, the signature will no longer match. As long as the secret is truly secret, then we can verify that what we're receiving is unaltered.
Because we're using class based routes via Flask-RESTful, we can take advantage of inheritance to make protecting API routes simple. Routes that require authentication will inherit from AuthenticatedView
, while public facing routes continue to use the Resource
base class.
The decode_cookie
function will use PyJWT to verify the token and store it in the Flask global context. We'll register the decoding function as a before_request
handler so that verifying and storing the token is the first step in the request lifecycle.
from app.services.auth import decode_cookie
def create_app():
app = Flask(__name__)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SQLALCHEMY_DATABASE_URI"] = create_db_uri()
app.config["SQLALCHEMY_POOL_RECYCLE"] = int(
os.environ.get("SQLALCHEMY_POOL_RECYCLE", 300)
)
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "placeholder_key")
app.config["SQLALCHEMY_ECHO"] = False
app.before_request_funcs.setdefault(None, [decode_cookie])
create_celery(app)
return app
The decode_cookie
function will run for every request, and before any route handler logic. This step only verifies the token and stores the object on g.cookie
– it does not authenticate the user. We'll see that happen later in the require_login
function. Below is the implementation for the decode_cookie
function.
import os
import logging
import jwt
from flask import g, request, abort
def decode_cookie():
cookie = request.cookies.get("user")
if not cookie:
g.cookie = {}
return
try:
g.cookie = jwt.decode(cookie, os.environ["SECRET_KEY"], algorithms=["HS256"])
except jwt.InvalidTokenError as err:
logging.warning(str(err))
abort(401)
Because this will run for every request, we simply return early if there is no cookie. We call the abort function with a 401 if the token fails to verify, so that the React front-end can redirect the user to the login page.
The require_login
function does the actual check against the database. At this point, we've verified the token, and have a user ID extracted from that token. Now we just need to make sure that the user ID matches a real user in the database.
import logging
from flask import make_response, g, abort
from flask_restful import Resource, wraps
from app.models.user import User
def require_login(func):
@wraps(func)
def wrapper(*args, **kwargs):
if "id" not in g.cookie:
logging.warning("No authorization provided!")
abort(401)
g.user = User.query.get(g.cookie["id"])
if not g.user:
response = make_response("", 401)
response.set_cookie("user", "")
return response
return func(*args, **kwargs)
return wrapper
class AuthenticatedView(Resource):
method_decorators = [require_login]
The decorator function also creates g.user
so that the User instance is available wherever we might need it. If, for some reason the given ID is not found in the database, then we clear the cookie and send the user back to the login page with a 401.
For this project, I want to walk through both traditional email/password sign-up, as well as using Google OAuth. Having run a SaaS app, I can say from my own experience that doing both worked out well – roughly half of users opted to use the Google OAuth option. Adding that option isn't too difficult, and I believe the convenience offered to the user is worth it.
To get started, let's take a look at the User
database model.
from werkzeug.security import generate_password_hash, check_password_hash
from app import db
class User(db.Model):
__tablename__ = "user"
__table_args__ = (db.UniqueConstraint("google_id"), db.UniqueConstraint("email"))
id = db.Column(db.Integer, primary_key=True)
# An ID to use as a reference when sending email.
external_id = db.Column(
db.String, default=lambda: str(uuid.uuid4()), nullable=False
)
google_id = db.Column(db.String, nullable=True)
activated = db.Column(db.Boolean, default=False, server_default="f", nullable=False)
# When the user chooses to set up an account directly with the app.
_password = db.Column(db.String)
given_name = db.Column(db.String, nullable=True)
email = db.Column(db.String, nullable=True)
picture = db.Column(db.String, nullable=True)
last_login = db.Column(db.DateTime, nullable=True)
@property
def password(self):
raise AttributeError("Can't read password")
@password.setter
def password(self, password):
self._password = generate_password_hash(password)
def verify_password(self, password):
return check_password_hash(self._password, password)
There are a few important things to note here. Firstly, this class uses property decorators for the password
attribute, meaning that while it may look like an attribute on the outside, we're actually calling methods when that attribute is accessed.
Take the following example.
user = User()
user.username = "Bob"
user.password = "PasswordForBob"
Here we set the password, but behind the scenes, the User class is using the one-way hashing function generate_password_hash
to create a scrambled version of the password that even we can't unscramble. The real value is stored in the _password
attribute. This process ensures that even if an attacker gained access to the database, they would not find any user passwords.
The UniqueConstraint
values added to the User class are also worth pointing out. Constraints at the database level are a great way to prevent certain kinds of bugs. Here we're saying it should be impossible to have two users with identical email addresses, or with the same Google ID. We'll also check for this situation in the Flask app, but it's good to have constraints as a fail-safe, in case there's a bug in the Python code.
Creating new users with an email and password (as opposed to Oauth) is fairly straightforward. Most of the work comes from verifying the email address!
I was lazy in the beginning when building my own SaaS and neglected email verification. If you offer any kind of free trial, you're inevitably going to have abuse. I had one individual creating dozens of accounts with fake email addresses. Beyond just abusing your free trial, these users damage your email sending reputation, making it more likely that your emails end up in the spam folder.
Requiring an activation step won't 100% solve this problem, but it will go a long way.
We'll need a way for the app to send email. I'm using the Mailgun API for this project, and set up only takes a few minutes of fiddling with DNS records. Once you have an account with Mailgun and the correct DNS records are in place, sending email only requires a few more steps.
First, we'll update the variables.env and app/init.py files with the necessary config values.
MAIL_DOMAIN
MAIL_SENDER
MAILGUN_API_KEY
POSTGRES_USER
POSTGRES_PASSWORD
POSTGRES_HOST
POSTGRES_DB
If you'll recall from earlier, the variables.env file determines which environment variables are passed from the host machine into the Docker containers. The new values here are MAIL_DOMAIN
and MAIL_SENDER
, which in my case are mail.openranktracker.com and [email protected] respectively. The MAILGUN_API_KEY
value is used to authenticate your requests to the Mailgun API.
Next we'll update the create_app
function to add these new values to the global config dictionary, so that we can access them from anywhere.
app.config["MAILGUN_API_KEY"] = os.environ["MAILGUN_API_KEY"]
app.config["MAIL_SUBJECT_PREFIX"] = "[OpenRankTracker]"
app.config["MAIL_SENDER"] = os.environ.get("MAIL_SENDER")
app.config["MAIL_DOMAIN"] = os.environ["MAIL_DOMAIN"]
Sending an email requires a single API call to Mailgun. We can use the Requests module to make that call, and we'll wrap it all up as a re-usable utility function.
def send_email(to, subject, template, **kwargs):
rendered = render_template(template, **kwargs)
response = requests.post(
"https://api.mailgun.net/v3/{}/messages".format(app.config["MAIL_DOMAIN"]),
auth=("api", app.config["MAILGUN_API_KEY"]),
data={
"from": app.config["MAIL_SENDER"],
"to": to,
"subject": app.config["MAIL_SUBJECT_PREFIX"] + " " + subject,
"html": rendered,
},
)
return response.status_code == 201
Unlike the user interface, which is rendered using React, we'll create the emails with server side rendering via Jinja templates. The app/templates directory will contain all of the email templates, starting with our email verification template. The send_email function accepts extra keyword arguments, which are then passed into render_template, allowing us to have whatever variables we need while rendering the template.
The app/templates/verify_email.html
template itself is very basic, but functional.
<p>Please follow the link below in order to verify your email address!</p>
<a href="{{ root_domain }}welcome/activate?user_uuid={{ user_uuid }}">Verify email and activate account</a>
The root_domain
variable makes this code independent of the server it's deployed to, so that if we had a staging or test server, it would continue to work there. The user_uuid
value is a long string of random letters and digits that identifies users outside of the system – we do this instead of using the primary key because it's best not to rely on an easily enumerated value that an attacker could iterate through.
When building a new template, keep in mind that most email clients support a limited subset of HTML and CSS – designing email templates, even today, will remind you of working with Internet Explorer 6.
The verification process is kicked off once a user registers with an email and password. They'll have access to the app immediately, but some features will be restricted until the activation step is complete. This will be easy to keep track of thanks to the activated
column on the user table.
Let's take a look at the signup.py
route handler.
from app.services.user import send_email
from app.serde.user import UserSchema
from app.models.user import User
from app import db
class SignUpView(Resource):
def post(self):
data = request.get_json()
user = User.query.filter(
func.lower(User.email) == data["email"].strip().lower()
).first()
if user:
abort(400, "This email address is already in use.")
user = User()
user.email = data["email"].strip()
user.password = data["password"].strip()
user.last_login = datetime.now()
db.session.add(user)
db.session.commit()
send_email(
user.email,
"Account activation",
"verify_email.html",
root_domain=request.url_root,
)
response = make_response("")
response.set_cookie(
"user",
jwt.encode(
UserSchema().dump(user), app.config["SECRET_KEY"], algorithm="HS256"
),
)
return response
This is pretty straightforward, but there are a few important "gotchas" to keep in mind. When checking whether an email is already registered, we're careful to make the comparison case insensitive and strip all white-space. The other point to remember here is that although we store the password in user.password
, the plain-text password is never permanently stored anywhere – the one-way hashed value is stored in the _password
table column.
The response returned to the client contains their new user details inside a JWT. From there, the front-end will send them to their app dashboard.
On the front-end side, we'd like to restrict certain pages to logged in users, while redirecting anyone else back to the login or signup area.
The first problem is how to determine whether a user is logged in or not. Because we're storing the JSON web token in a cookie, we'll use the js-cookie library to handle retrieving the cookie, and jwt-decode to parse the token itself. We'll perform a check in src/App.js when the page first loads to determine if the user has a token.
const App = () => {
const [loadingApp, setLoadingApp] = useState(true);
const [loggedIn, setLoggedIn] = useState(false);
/*
** Check for a user token when the app initializes.
**
** Use the loadingApp variable to delay the routes from
** taking effect until loggedIn has been set (even logged in
** users would be immediately redirected to login page
** otherwise).
*/
useEffect(() => {
setLoggedIn(!!getUser());
setLoadingApp(false);
}, []);
return (
<UserContext.Provider value={{ loggedIn, setLoggedIn }}>
{!loadingApp && (
<Router style={{ minHeight: "100vh" }}>
<Splash path="/welcome/*" />
<ProtectedRoute path="/*" component={Home} />
</Router>
)}
</UserContext.Provider>
);
};
The UserContext
is provided at the top level of the app, so code anywhere can determine if the user is currently logged in, and potentially change that state. The ProtectedRoute
component simply wraps another component, and prevents that component from loading if the user isn't logged in, instead sending them back to the login page.
If we take a look at ProtectedRoute
, we can see that it uses the UserContext
to determine if it should load the wrapped component, or redirect to the login page.
const ProtectedRoute = ({ component: Component }) => {
const { loggedIn } = useContext(UserContext);
return loggedIn ? (
<Component />
) : (
<Redirect from="" to="welcome/login" noThrow />
);
};
As a bonus, now we'll turn to adding Google Oauth as a sign up and login option. You'll first need to create an account to access Google Developer Console if you haven't already.
After that, you'll need to configure what Google labels as the Oauth consent screen – this is the pop-up that users will see asking them to authorize your app. This step is filled with warnings about manual reviews, but as long as you avoid any sensitive or restricted scopes (i.e. account permissions), your consent screen should be immediately approved. Our app requires the non-sensitive OpenID and email scopes.
After configuring your consent screen, create a new Oauth 2.0 client under the Credentials tab. This is where you'll define your authorized origins and redirect URIs, or in other words, where the Oauth process is allowed to start from, and where the user should return to after interacting with the Google account page.
This is an example of my own settings. You'll also find your Client ID and secret on this page.
The GOOGLE_CLIENT_ID
and GOOGLE_CLIENT_SECRET
environment vars will need to find their way into variables.env
so that the app container can pick them up.
The Flask app has 4 separate endpoints that handle the Oauth flow. The route handlers contained in oauthsignup.py
and oauthlogin.py
are very simple, and just redirect the browser over to Google while generating a callback URL. The React front-end will do a form submission to one of these, causing the browser to leave our application.
from flask import request, redirect
from flask_restful import Resource
from app.services.auth import oauth2_request_uri
class Oauth2SignUpView(Resource):
def post(self):
return redirect(
oauth2_request_uri(request.url_root + "api/users/oauth2callback/signup/")
)
Once the user has chosen an account for sign-up or login, they're directed back to our application using the Oauth2 request URI that we generated previously.
The sign-up and login callback handlers are actually very similar, except that during a login attempt the user must already exist. We could easily allow an oAuth login attempt to create a user if none exists, but this leads to confusion, as users forget which email account they used to sign in to the app.
This is the sign-up route handler that will execute when Google redirects the browser back to our domain.
from app.services.auth import get_user_info
from app.serde.user import UserSchema
from app.models.user import User
from app import db
class Oauth2SignUpCallbackView(Resource):
def get(self):
oauth_code = request.args.get("code")
userinfo = get_user_info(oauth_code)
google_id = userinfo["sub"]
# Find existing authenticated Google ID or an existing email that the
# user previously signed up with (they're logging in via Google for
# the first time).
user = User.query.filter(
or_(
User.google_id == google_id,
func.lower(User.email) == userinfo["email"].lower(),
)
).first()
if not user:
user = User()
user.google_id = google_id
user.given_name = userinfo["given_name"]
user.email = userinfo["email"]
user.last_login = datetime.now()
user.activated = True
db.session.add(user)
db.session.commit()
response = redirect(request.url_root)
response.set_cookie(
"user",
jwt.encode(
UserSchema().dump(user), app.config["SECRET_KEY"], algorithm="HS256"
),
)
return response
The get_user_info
utility function combines the oAuth code returned from Google with our client ID and secret in order to fetch non-sensitive data about the user, including email address and given name.
The route handler also checks the database for an existing user, just to make sure we aren't creating new users when an existing user hits sign-up again for any reason. I've also chosen to sync up non-oAuth users with their Google ID if they should hit "Sign Up with Google" after going through the traditional sign-up process.
Remember that all of the code is on GitHub if you'd like to use this project as an example for setting up oAuth in your own application.
In part five, we'll start working on the user dashboard, where we'll display ranking progress for the domains and keywords they are tracking.
16