DEV Community

Cover image for Building a User Authentication API using Python Flask and MySQL
Andrew Eze
Andrew Eze

Posted on • Edited on

Building a User Authentication API using Python Flask and MySQL

Recently, I decided to learn Python, as part of learning I built a remote jobs (remote4africa) platform using Python Flask. In this post, I will show you step by step process for building a user authentication API using Python (Flask) and MySQL. The application will allow users to register and verify their email through an OTP Code sent to their email.

Note: This post assumes you have an understanding of Python Flask and MySQL.

Flask is a micro web framework written in Python. It allows python developers to build APIs or full web app (frontend and Backend).

When building a user authentication system, you should consider security and ease of use.
On security, you should not allow users to use weak passwords, especially if you are working on a critical application. You should also encrypt the password before storing in your database.

To build this I used the following dependencies:
cerberus: For validation
alembic: For Database Migration and Seeding
mysqlclient: For connecting to MySQL
Flask-SQLAlchemy, flask-marshmallow and marshmallow-sqlalchemy: For database object relational mapping
pyjwt: For JWT token generation
Flask-Mail: For email sending
celery and redis: For queue management

So let's get started

Step 1: Install and Set up your Flask Project.

You can follow the guide at Flask Official Documentation site or follow the steps below. Please ensure you have python3 and pip installed in your machine.

$ mkdir flaskauth $ cd flaskauth $ python3 -m venv venv $ . venv/bin/activate $ pip install Flask # pip install requirements.txt 
Enter fullscreen mode Exit fullscreen mode

You can install your all the required packages together with Flask at once using a requirements.txt file. (This is provided in the source code).

The Python Flask App folder Structure
It is always advisable to use the package pattern to organise your project.

/yourapplication /yourapplication __init__.py /static style.css /templates layout.html index.html login.html ... 
Enter fullscreen mode Exit fullscreen mode

Add a pyproject.toml or setup.py file next to the inner yourapplication folder with the following contents:

pyproject.toml

[build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] name = "yourapplication" description = "yourapplication description" readme = "README.rst" version="1.0.0" requires-python = ">=3.11" dependencies = [ "Flask", "SQLAlchemy", "Flask-SQLAlchemy", "wheel", "pyjwt", "datetime", "uuid", "pytest", "coverage", "python-dotenv", "alembic", "mysqlclient", "flask-marshmallow", "marshmallow-sqlalchemy", "cerberus", "Flask-Mail", "celery", "redis" ] 
Enter fullscreen mode Exit fullscreen mode

setup.py

from setuptools import setup setup( name='yourapplication', packages=['yourapplication'], include_package_data=True, install_requires=[ 'flask', ], py_modules=['config'] ) 
Enter fullscreen mode Exit fullscreen mode

You can then install your application so it is importable:

$ pip install -e . 
Enter fullscreen mode Exit fullscreen mode

You can use the flask command and run your application with the --app option that tells Flask where to find the application instance:

$ flask –app yourapplication run 
Enter fullscreen mode Exit fullscreen mode

In our own case the application folder looks like the following:

/flaskauth /flaskauth __init__.py /models /auth /controllers /service /queue /templates config.py setup.py pyproject.toml /tests .env ... 
Enter fullscreen mode Exit fullscreen mode

Step 2: Set up Database and Models

Since this is a simple application, we just need few tables for our database:

  • users
  • countries
  • refresh_tokens

We create our baseModel models/base_model.py

from flask_sqlalchemy import SQLAlchemy from flask_marshmallow import Marshmallow from sqlalchemy.ext.declarative import declared_attr from flaskauth import app _PLURALS = {"y": "ies"} db = SQLAlchemy() ma = Marshmallow(app) class BaseModel(object): @declared_attr def __tablename__(cls): name = cls.__name__ if _PLURALS.get(name[-1].lower(), False): name = name[:-1] + _PLURALS[name[-1].lower()] else: name = name + "s" return name 
Enter fullscreen mode Exit fullscreen mode

For Users table, we need email, first_name, last_name, password and other fields shown below. So we create the user and refresh token model models/user.py

from datetime import datetime from flaskauth.models.base_model import BaseModel, db, ma from flaskauth.models.country import Country class User(db.Model, BaseModel): __tablename__ = "users" id = db.Column(db.BigInteger, primary_key=True) email = db.Column(db.String(120), unique=True, nullable=False) password = db.Column(db.String(200), nullable=True) first_name = db.Column(db.String(200), nullable=False) last_name = db.Column(db.String(200), nullable=False) avatar = db.Column(db.String(250), nullable=True) country_id = db.Column(db.Integer, db.ForeignKey('countries.id', onupdate='CASCADE', ondelete='SET NULL'), nullable=True) is_verified = db.Column(db.Boolean, default=False, nullable=False) verification_code = db.Column(db.String(200), nullable=True) created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) updated_at = db.Column(db.DateTime, nullable=True, onupdate=datetime.utcnow) deleted_at = db.Column(db.DateTime, nullable=True) country = db.relationship('Country', backref=db.backref('users', lazy=True)) refresh_tokens = db.relationship('RefreshToken', backref=db.backref('users', lazy=True)) class RefreshToken(db.Model, BaseModel): __tablename__ = "refresh_tokens" id = db.Column(db.BigInteger, primary_key=True) token = db.Column(db.String(200), unique=True, nullable=False) user_id = db.Column(db.BigInteger, db.ForeignKey(User.id, onupdate='CASCADE', ondelete='CASCADE'), nullable=False) expired_at = db.Column(db.DateTime, nullable=False) created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) updated_at = db.Column(db.DateTime, nullable=True, onupdate=datetime.utcnow) 
Enter fullscreen mode Exit fullscreen mode

We also create the country model

from flaskauth.models.base_model import BaseModel, db from flaskauth.models.region import Region class Country(db.Model, BaseModel): __tablename__ = "countries" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), unique=True, nullable=False) code = db.Column(db.String(3), unique=True, nullable=False) 
Enter fullscreen mode Exit fullscreen mode

For migration and seeding (creating the tables on the database and importing default data), we will use alembic. I will show you how to do this later.

Step 3: Create the __init__.py file

In this file we will initiate the flask application, establish database connection and also set up the queue. The init.py file makes Python treat directories containing it as modules.

import os from flask import Flask, make_response, jsonify from jsonschema import ValidationError def create_app(test_config=None): app = Flask(__name__, instance_relative_config=True) if test_config is None: app.config.from_object('config.ProductionConfig') else: app.config.from_object('config.DevelopmentConfig') # ensure the instance folder exists try: os.makedirs(app.instance_path) except OSError: pass return app app = create_app(test_config=None) from flaskauth.models.base_model import db, BaseModel db.init_app(app) from flaskauth.models.user import User from celery import Celery def make_celery(app): celery = Celery(app.name) celery.conf.update(app.config["CELERY_CONFIG"]) class ContextTask(celery.Task): def __call__(self, *args, **kwargs): with app.app_context(): return self.run(*args, **kwargs) celery.Task = ContextTask return celery celery = make_celery(app) from flaskauth.auth import auth from flaskauth.queue import queue from flaskauth.controllers import user app.register_blueprint(auth, url_prefix='/auth') app.register_blueprint(queue) @app.route("/hello") def hello_message() -> str: return jsonify({"message": "Hello It Works"}) @app.errorhandler(400) def bad_request(error): if isinstance(error.description, ValidationError): original_error = error.description return make_response(jsonify({'error': original_error.message}), 400) # handle other "Bad Request"-errors # return error return make_response(jsonify({'error': error.description}), 400) 
Enter fullscreen mode Exit fullscreen mode

This file loads our application by calling all the models and controllers needed.

The first function create_app is for creating a global Flask instance, it is the assigned to app

app = create_app(test_config=None) 
Enter fullscreen mode Exit fullscreen mode

We then import our database and base model from models/base_model.py

We are using SQLAlchemy as ORM for our database, we also use Marshmallow for serialization/deserialization of our database objects.
With the imported db, we initiate the db connection.

Next we use the make_celery(app) to initiate the celery instance to handle queue and email sending.

Next we import the main parts of our applications (models, controllers and other functions).

app.register_blueprint(auth, url_prefix='/auth') app.register_blueprint(queue) 
Enter fullscreen mode Exit fullscreen mode

The above will register the queue and auth blueprints. In the auth blueprint we handle all routes that starts with /auth

@app.route("/hello") def hello_message() -> str: return jsonify({"message": "Hello It Works"}) 
Enter fullscreen mode Exit fullscreen mode

We will use this to test that our application is running.

The bad_request(error): function will handle any errors not handled by our application.

The Authentication

To handle authentication we will create a file auth/controllers.py
This file has the register function that will handle POST request. We also need to handle data validation to ensure the user is sending the right information.

from werkzeug.security import generate_password_hash, check_password_hash from flaskauth import app from flaskauth.auth import auth from flask import request, make_response, jsonify, g, url_for from datetime import timedelta, datetime as dt from flaskauth.models.user import db, User, RefreshToken, UserSchema from sqlalchemy.exc import SQLAlchemyError from cerberus import Validator, errors from flaskauth.service.errorhandler import CustomErrorHandler from flaskauth.queue.email import send_email from flaskauth.service.tokenservice import otp, secret, jwtEncode from flaskauth.service.api_response import success, error @auth.route("/register", methods=['POST']) def register(): schema = { 'email': { 'type': 'string', 'required': True, 'regex': '^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$' }, 'password': { 'type': 'string', 'required': True, 'min': 6 }, 'first_name': { 'type': 'string', 'required': True, 'min': 2 }, 'last_name': { 'type': 'string', 'required': True, 'min': 2, } } v = Validator(schema, error_handler=CustomErrorHandler) form_data = request.get_json() args = request.args if(v.validate(form_data, schema) == False): return v.errors email = form_data['email'] verification_code = otp(7) try: new_user = User( email= form_data['email'], password = generate_password_hash(form_data['password']), first_name= form_data['first_name'], last_name= form_data['last_name'], verification_code = secret(verification_code), ) db.session.add(new_user) db.session.commit() except SQLAlchemyError as e: # error = str(e.__dict__['orig']) message = str(e) return error({}, message, 400) # return make_response(jsonify({'error': error}), 400) # Send verification email appName = app.config["APP_NAME"].capitalize() email_data = { 'subject': 'Account Verification on ' + appName, 'to': email, 'body': '', 'name': form_data['first_name'], 'callBack': verification_code, 'template': 'verification_email' } send_email.delay(email_data) message = 'Registration Successful, check your email for OTP code to verify your account' return success({}, message, 200) 
Enter fullscreen mode Exit fullscreen mode

For validation I used cerberus. Cerberus is a python package that makes validation easy, it returns errors in json format when a validation fail. You can also provide custom error message like below.

from cerberus import errors class CustomErrorHandler(errors.BasicErrorHandler): messages = errors.BasicErrorHandler.messages.copy() messages[errors.REGEX_MISMATCH.code] = 'Invalid Email!' messages[errors.REQUIRED_FIELD.code] = '{field} is required!' 
Enter fullscreen mode Exit fullscreen mode

The register function validates the data, if successful, we then generate an otp/verification code using the otp(total) function. We then hash the code for storage in database using the secret(code=None) function.

def secret(code=None): if not code: code = str(datetime.utcnow()) + otp(5) return hashlib.sha224(code.encode("utf8")).hexdigest() def otp(total): return str(''.join(random.choices(string.ascii_uppercase + string.digits, k=total))) 
Enter fullscreen mode Exit fullscreen mode

We hash the user's password using the werkzeug.security generate_password_hash inbuilt function in flask and store the record in database. We are using SQLAlchemy to handle this.

After storing the user details, we schedule email to be sent immediately to the user.

# Send verification email appName = app.config["APP_NAME"].capitalize() email_data = { 'subject': 'Account Verification on ' + appName, 'to': email, 'body': '', 'name': form_data['first_name'], 'callBack': verification_code, 'template': 'verification_email' } send_email.delay(email_data) 
Enter fullscreen mode Exit fullscreen mode

Then, we return a response to the user

message = 'Registration Successful, check your email for OTP code to verify your account' return success({}, message, 200) 
Enter fullscreen mode Exit fullscreen mode

To handle responses, I created a success and error functions under services/api_response.py

from flask import make_response, jsonify def success(data, message: str=None, code: int = 200): data['status'] = 'Success' data['message'] = message data['success'] = True return make_response(jsonify(data), code) def error(data, message: str, code: int): data['status'] = 'Error' data['message'] = message data['success'] = False return make_response(jsonify(data), code) 
Enter fullscreen mode Exit fullscreen mode

We have other functions to handle email verification with OTP, login and refresh_token.

Verify account

@auth.route("/verify", methods=['POST']) def verifyAccount(): schema = { 'otp': { 'type': 'string', 'required': True, 'min': 6 }, } v = Validator(schema, error_handler=CustomErrorHandler) form_data = request.get_json() if(v.validate(form_data, schema) == False): return v.errors otp = form_data['otp'] hash_otp = secret(otp) user = User.query.filter_by(verification_code = hash_otp).first() if not user: message = 'Failed to verify account, Invalid OTP Code' return error({},message, 401) user.verification_code = None user.is_verified = True db.session.commit() message = 'Verification Successful! Login to your account' return success({}, message, 200) 
Enter fullscreen mode Exit fullscreen mode

Login user

This function handles POST request and validates the user's email and password. We first check if a record with the user's email exists, if not, we return an error response. If user's email exists, we check_password_hash function to check if the password supplied is valid.
If the password is valid, we call the authenticated function to generate and store a refresh token, generate a JWT token and the return a response with both tokens to the user.

@auth.route("/login", methods=['POST']) def login(): schema = { 'email': { 'type': 'string', 'required': True, 'regex': '^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$' }, 'password': { 'type': 'string', 'required': True, 'min': 6 }, } v = Validator(schema, error_handler=CustomErrorHandler) form_data = request.get_json() user = User.query.filter_by(email = form_data['email']).first() if not user: message = 'Login failed! Invalid account.' return error({}, message, 401) if not check_password_hash(user.password, form_data['password']): message = 'Login failed! Invalid password.' return error({}, message, 401) return authenticated(user) def authenticated(user: User): refresh_token = secret() try: refreshToken = RefreshToken( user_id = user.id, token = refresh_token, expired_at = dt.utcnow() + timedelta(minutes = int(app.config['REFRESH_TOKEN_DURATION'])) ) db.session.add(refreshToken) db.session.commit() except SQLAlchemyError as e: # error = str(e.__dict__['orig']) message = str(e) return error({}, message, 400) # del user['password'] user_schema = UserSchema() data = { "token": jwtEncode(user), "refresh_token": refresh_token, "user": user_schema.dump(user) } message = "Login Successful, Welcome Back" return success(data, message, 200) 
Enter fullscreen mode Exit fullscreen mode

The response with the token will look like this:

{ "expired_at": "Wed, 10 Jan 2024 16:43:48 GMT", "message": "Login Successful, Welcome Back", "refresh_token": "a5b5ehghghjk8truur9kj4f999bf6c01d34892df768", "status": "Success", "success": true, "token": "eyK0eCAiOiJDhQiLCJhbGciOI1NiJ9.eyJzdWIiOjEsImlhdCI6MTY3MzM2OTAyOCwiZXhwIjoxNjczOTczODI4fQ.a6fn7z8v9K5EmqZO7-J8VkY2u_Kdffh8aOVuWjTH138", "user": { "avatar": null, "country": { "code": "NG", "id": 158, "name": "Nigeria" }, "country_id": 158, "created_at": "2022-12-09T16:00:48", "email": "user@example.com", "first_name": "John", "id": 145, "is_verified": true, "last_name": "Doe", "updated_at": "2022-12-10T12:17:45" } } 
Enter fullscreen mode Exit fullscreen mode

The refresh token is useful for keeping a user logged without requesting for login information every time. Once JWT token is expired a user can use the refresh token to request a new JWT token. This can be handled by the frontend without asking the user for login details.

@auth.route("/refresh", methods=['POST']) def refreshToken(): schema = { 'refresh_token': { 'type': 'string', 'required': True, }, } v = Validator(schema, error_handler=CustomErrorHandler) form_data = request.get_json() now = dt.utcnow() refresh_token = RefreshToken.query.filter(token == form_data['refresh_token'], expired_at >= now).first() if not refresh_token: message = "Token expired, please login" return error({}, message, 401) user = User.query.filter_by(id = refresh_token.user_id).first() if not user: message = "Invalid User" return error({}, message, 403) data = { "token": jwtEncode(user), "id": user.id } message = "Token Successfully refreshed" return success(data, message, 200) 
Enter fullscreen mode Exit fullscreen mode

You can access the full source code on GitHub HERE

Step 4: Install and Run the application

You can run the application by executing the following on your terminal

flask --app flaskauth run 
Enter fullscreen mode Exit fullscreen mode

Ensure the virtual environment is active and you're on the root project folder when you run this.
Alternatively, you don't need to be in the root project folder to run the command if you installed the application using the command below

$ pip install -e . 
Enter fullscreen mode Exit fullscreen mode

To ensure email is delivering set up SMTP crediential in the .env file

APP_NAME=flaskauth FLASK_APP=flaskauth FLASK_DEBUG=True FLASK_TESTING=False SQLALCHEMY_DATABASE_URI=mysql://root:@localhost/flaskauth_db?charset=utf8mb4 MAIL_SERVER='smtp.mailtrap.io' MAIL_USERNAME= MAIL_PASSWORD= MAIL_PORT=2525 MAIL_USE_TLS=True MAIL_USE_SSL=False MAIL_DEFAULT_SENDER='info@flaskauth.app' MAIL_DEBUG=True SERVER_NAME= AWS_SECRET_KEY= AWS_KEY_ID= AWS_BUCKET= JWT_DURATION=10080 REFRESH_TOKEN_DURATION=525600 
Enter fullscreen mode Exit fullscreen mode

Also, run the celery application to handle queue and email sending

celery -A flaskauth.celery worker --loglevel=INFO 
Enter fullscreen mode Exit fullscreen mode

Step 5: Run Database Migration and Seeding

We will use alembic package to handle migration. Alembic is already installed as part of our requirements. Also, see the source code for the migration files. We will run the following to migrate.

alembic upgrade head 
Enter fullscreen mode Exit fullscreen mode

You can use alembic to generate database migrations. For example, to create users table run

alembic revision -m "create users table" 
Enter fullscreen mode Exit fullscreen mode

This will generate a migration file, similar to the following:

"""create users table Revision ID: 9d4a5cb3f558 Revises: 5b9768c1b705 Create Date: 2023-01-10 18:34:09.608403 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '9d4a5cb3f558' down_revision = '5b9768c1b705' branch_labels = None depends_on = None def upgrade() -> None: pass def downgrade() -> None: pass 
Enter fullscreen mode Exit fullscreen mode

You can then edit it to the following:

"""create users table Revision ID: 9d4a5cb3f558 Revises: None Create Date: 2023-01-10 18:34:09.608403 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '9d4a5cb3f558' down_revision = None branch_labels = None depends_on = None def upgrade() -> None: op.create_table('users', sa.Column('id', sa.BigInteger(), nullable=False), sa.Column('email', sa.String(length=120), nullable=False), sa.Column('password', sa.String(length=200), nullable=True), sa.Column('first_name', sa.String(length=200), nullable=False), sa.Column('last_name', sa.String(length=200), nullable=False), sa.Column('avatar', sa.String(length=250), nullable=True), sa.Column('country_id', sa.Integer(), nullable=True), sa.Column('is_verified', sa.Boolean(), nullable=False), sa.Column('verification_code', sa.String(length=200), nullable=True), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('updated_at', sa.DateTime(), nullable=True), sa.Column('deleted_at', sa.DateTime(), nullable=True), sa.ForeignKeyConstraint(['country_id'], ['countries.id'], onupdate='CASCADE', ondelete='SET NULL'), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('email') ) # ### end Alembic commands ### def downgrade() -> None: op.drop_table('users') # ### end Alembic commands ### 
Enter fullscreen mode Exit fullscreen mode

You can also auto generate migration files from your models using the following:

alembic revision --autogenerate 
Enter fullscreen mode Exit fullscreen mode

Note: For auto generation to work, you have to import your app context into the alembic env.py file. See source code for example.

Conclusion

After completing the database migration, you can go ahead and test the app buy sending requests to http://localhost:5000/auth/register using Postman, remember to supply all the necessary data, example below:

{ "email": "ade@example.com", "password": "password", "first_name": "Ade", "last_name": "Emeka" "country": "NG" } 
Enter fullscreen mode Exit fullscreen mode

Let me know your thoughts and feedback below.

Top comments (1)

Collapse
 
rudra0x01 profile image
Rudra Sarkar

well written, write some devops projects