FlaskSimpleAuth Tutorial
In this tutorial, you will build a secure REST HTTP Python WSGI back end using FlaskSimpleAuth, a Flask extension. It will feature basic and parameter password authentication (who is the user?), as well as group and object authorizations (permissions associated to the authenticated who). This is not very different from starting a Flask project, but if you start with Flask you will have to unlearn things as FlaskSimpleAuth framework extends and simplifies Flask on key points.
This tutorial assumes a working knowledge of the HTTP protocol in a REST
API context, advance programming in Python
, and interacting from a terminal
with a shell.
It should work with Python 3.1x on Unix (Linux, MacOS) and possibly Windows with
WSL.
It is advisable to use a version control tool such as git
to commit the
tutorial state after each section.
Application Setup
Let us first create a minimal running REST application back end without
authentication and authorizations.
Create and activate a Python virtual environment in a new directory, from a
shell terminal:
python --version # must show 3.1x
mkdir fsa-tuto
cd fsa-tuto
python -m venv venv
source venv/bin/activate
pip install FlaskSimpleAuth[password]
Using your favorite text editor, create in the fsa-tuto
directory the app.py
file with an open (unauthenticated) GET /hello
route.
The authorize
route parameter is mandatory to declare authorization
requirements on the route.
If not set, the route would be closed (aka 403).
# file "app.py"
# necessary for debug messages to show up…
import logging
logging.basicConfig()
# Flask initialization
import FlaskSimpleAuth as fsa
app = fsa.Flask("acme")
app.config.from_envvar("ACME_CONFIG")
# TODO LATER MORE INITIALIZATIONS
# GET /hello route, not authenticated
@app.get("/hello", authorize="OPEN")
def get_hello():
return { "msg": "hello", "version": fsa.__version__ }, 200
# TODO LATER MORE ROUTES
Create the acme.conf
configuration file:
# file "acme.conf"
FSA_AUTH = "none" # allow non authenticated routes (aka OPEN authorization)
FSA_MODE = "debug1" # debug level 1, max is 4
FSA_ADD_HEADERS = { "Application": "Acme" }
Start the application in a terminal with the flask local test server.
export ACME_CONFIG="acme.conf" # where to find the configuration file
flask --app ./app.py run --debug --reload
# various log traces...
# control-c to stop
Test the route, for instance using curl
from another terminal:
curl -si -X GET http://localhost:5000/hello # 200
You should see a log line for the request in the application terminal, possibly some debug output, and the JSON response in the second terminal, with 3 FSA-specific headers telling the request, the authentication and execution time from the framework point of view:
HTTP/1.1 200 OK
Server: Werkzeug/... Python/...
Date: ...
Application: Acme
FSA-Request: GET /hello
FSA-User: None (None)
FSA-Delay: 0.000666
Content-Type: application/json
Content-Length: 42
Connection: close
{
"msg": "hello",
"version": "33.1"
}
It is good practice to automate application tests, for instance with
pytest
.
Create a test.py
file with a test to cover this route:
# file "test.py"
import pytest
from app import app
@pytest.fixture
def client():
with app.test_client() as client:
yield client
def test_hello(client):
res = client.get("/hello") # GET /hello
assert res.status_code == 200
assert res.json["msg"] == "hello"
# TODO MORE TESTS
Install and run pytest
:
pip install pytest
pytest test.py # 1 passed
Acme Database
Our incredible application will hold some data in a toy Acme database with
Users who can own Stuff at a price.
Create file acme.py
to manage a simplistic in-memory database implemented
as the AcmeData
class:
# file "acme.py"
import re
import FlaskSimpleAuth as fsa
class AcmeData:
def __init__(self):
# Users: login -> (password_hash, email, is_admin)
self.users: dict[str, list[str, str, bool]] = {}
# Stuff: name -> (owner, price)
self.stuff: dict[str, list[str, float]] = {}
def user_exists(self, login: str) -> bool:
return login in self.users
def add_user(self, login: str, password: str, email: str, admin: bool) -> None:
self.user_exists(login) and fsa.err(f"cannot overwrite existing user: {login}", 409)
re.match(r"^[a-z][a-z0-9]+$", login) or fsa.err(f"invalid login name: {login}", 400)
self.users[login] = [password, email, admin]
def get_user_pass(self, login: str) -> str|None:
return self.users[login][0] if login in self.users else None
def user_is_admin(self, login: str) -> bool:
return self.users[login][2] if login in self.users else False
def add_stuff(self, stuff: str, login: str, price: float) -> None:
stuff in self.stuff and fsa.err(f"cannot overwrite existing stuff: {stuff}", 409)
login in self.users or fsa.err(f"no such user: {login}", 404)
self.stuff[stuff] = [login, price]
def get_user_stuff(self, login: str) -> list[tuple[str, float]]:
login in self.users or fsa.err(f"no such user: {login}", 404)
return [ (stuff, row[1]) for stuff,row in self.stuff.items() if row[0] == login ]
def change_stuff(self, stuff: str, price: float) -> None:
stuff in self.stuff or fsa.err(f"no such stuff: {stuff}", 404)
self.stuff[stuff][1] = price
This class can be tested with test.py
:
# append to "test.py"
import acme
def test_acmedata():
db = acme.AcmeData()
# users
assert not db.user_exists("susie")
db.add_user("susie", "susie-pass", "susie@acme.org", True)
db.add_user("calvin", "calvin-pass", "calvin@acme.org", False)
assert db.user_exists("susie") and db.user_exists("calvin")
assert db.get_user_pass("susie") == "susie-pass"
assert db.get_user_pass("calvin") == "calvin-pass"
assert db.user_is_admin("susie") and not db.user_is_admin("calvin")
# stuff
db.add_stuff("pencil", "susie", 3.12)
db.add_stuff("toy", "calvin", 2.72)
assert db.get_user_stuff("calvin") == [ ("toy", 2.72) ]
db.change_stuff("pencil", 3.14)
assert db.get_user_stuff("susie") == [ ("pencil", 3.14) ]
# FIXME should also test errors...
Run pytest
as before to achieve 2 passed.
Basic Authentication
Let us now add new routes with basic authentication, which requires to:
configure the application.
store user credentials somewhere.
provide a password callback.
create authenticated routes.
Edit the acme.conf
file to tell about basic authentication:
# update "acme.conf"
FSA_AUTH = ["basic", "none"]
FSA_REALM = "acme" # the app name, also the default
For non trivial projects, it is good practice to split the application in
several files.
This creates an annoying chicken-and-egg issue with Python initializations.
A common pattern is to define init_app(app: Flask)
initialization functions
in each file, to call them from the main application file, and to use proxy
objects to avoid loading ordering issues.
Create a database.py
file which will hold our application primitive database
interface:
# file "database.py"
import os
import FlaskSimpleAuth as fsa
import acme
# this is a proxy object to the actual database created when calling init_app
db = fsa.Reference()
# application database initialization
def init_app(app: fsa.Flask):
# initialize proxy object
db.set(acme.AcmeData())
# add user acme as an admin
db.add_user("acme", app.hash_password(os.environ["ACME_PASS"]), "acme@acme.org", True)
Create an auth.py
file for the authentication and authorization callbacks:
# file "auth.py"
import FlaskSimpleAuth as fsa
# the database is needed!
from database import db
# FlaskSimpleAuth password authentication hook
def get_user_pass(login: str) -> str|None:
# NOTE returning None would work as well, but the result would be cached
db.user_exists(login) or fsa.err(f"no such user: {login}", 401)
return db.get_user_pass(login)
# TODO MORE CALLBACKS
# application auth initialization
def init_app(app: fsa.Flask):
# register password hook
app.get_user_pass(get_user_pass)
# app.get_user_pass(db.get_user_pass) # would work as well…
# TODO MORE REGISTRATIONS
Edit the app.py
file to import and initialize database and auth:
# insert in "app.py" initialization
import database
database.init_app(app)
db = database.db
import auth
auth.init_app(app)
And add routes which can be accessed by AUTH-enticated users:
# append to "app.py" routes
# all authenticated users can access this route
@app.get("/hello-me", authorize="AUTH")
def get_hello_me(user: fsa.CurrentUser):
return { "msg": "hello", "user": user }, 200
# users can add stuff for themselves
@app.post("/stuff", authorize="AUTH")
def post_stuff(stuff: str, price: float, user: fsa.CurrentUser):
db.add_stuff(stuff, user, price)
return f"stuff added: {stuff}", 201
# and consult them
@app.get("/stuff", authorize="AUTH")
def get_stuff(user: fsa.CurrentUser):
return fsa.jsonify(db.get_user_stuff(user)), 200
The user
parameter will be automatically filled with the name of the
authenticated user.
Other parameters are filled and converted from the request HTTP or JSON
parameters.
Set the admin password in the environment, in each terminal:
# hint for 64+ bit random password: head -c 9 /dev/random | base64
export ACME_PASS="<a-good-admin-password>"
Restart and test the application:
curl -si -X GET http://localhost:5000/hello-me # 401
curl -si -X GET -u 'meca:Mec0!' http://localhost:5000/hello-me # 401
curl -si -X GET -u "acme:$ACME_PASS" http://localhost:5000/hello-me # 200
curl -si -X POST -u "acme:$ACME_PASS" \
-d stuff=pinte -d price=6.5 http://localhost:5000/stuff # 201
curl -si -X POST -u "acme:$ACME_PASS" \
-d stuff=pinte -d price=6.5 http://localhost:5000/stuff # 409
curl -si -X GET -u "acme:$ACME_PASS" http://localhost:5000/stuff # 200
Also append these same tests to test.py
, and run them with pytest
to
achieve 3 passed:
# append to "test.py"
import os
import base64
# NOTE basic auth should be managed by the test client…
# see https://pypi.org/project/FlaskTester/ for instance
def basic_auth(login: str, passwd: str) -> dict[str, str]:
encoded = base64.b64encode(f"{login}:{passwd}".encode("UTF8"))
return { "Authorization": f"Basic {encoded.decode('ascii')}" }
ACME_BASIC = basic_auth("acme", os.environ["ACME_PASS"])
MECA_PASS = "Mec0!"
MECA_BASIC = basic_auth("meca", MECA_PASS)
def test_basic_authn(client):
res = client.get("/hello-me")
assert res.status_code == 401
res = client.get("/hello-me", headers=MECA_BASIC)
assert res.status_code == 401
res = client.get("/hello-me", headers=ACME_BASIC)
assert res.status_code == 200
assert res.json["user"] == "acme"
res = client.post("/stuff", headers=ACME_BASIC, json={"stuff": "pinte", "price": 6.5})
assert res.status_code == 201
res = client.post("/stuff", headers=ACME_BASIC, json={"stuff": "pinte", "price": 6.5})
assert res.status_code == 409
res = client.get("/stuff", headers=ACME_BASIC)
assert res.status_code == 200 and res.json[0][0] == "pinte"
# FIXME should cleanup data
Param Authentication
Another common way to authenticate a user is to provide the credentials as
request parameters.
This is usually done once to get some token (bearer, cookie…) which will be
used to access other routes, as discussed in the next section.
Initialization requirements are the same as for basic authentication, as
retrieving the user password is also needed.
To enable parameter authentication as well as basic authentication, simply
update the FSA_AUTH
configuration directive in acme.conf
:
# update "acme.conf"
FSA_AUTH = ["basic", "param", "none"]
Which parameters are used for authentication is also configurable in
acme.conf
:
# append to "acme.conf"
FSA_PARAM_USER = "username" # parameter for the user name (default is USER)
FSA_PARAM_PASS = "password" # parameter for the password (default is PASS)
Test from a terminal:
curl -si -X GET -d username=acme -d password="$ACME_PASS" http://localhost:5000/hello-me # 200
Also append these same tests to test.py
, and run them with pytest
to
achieve 4 passed:
# append to "test.py"
def test_param_authn(client):
# authentication parameters
acme_auth_params = {"username": "acme", "password": os.environ["ACME_PASS"]}
# passed as HTTP parameters
res = client.get("/hello-me", data=acme_auth_param)
assert res.status_code == 200 and res.json["user"] == "acme"
# or as JSON parameters
res = client.get("/hello-me", json=acme_auth_param)
assert res.status_code == 200 and res.json["user"] == "acme"
Authentication is often managed with tokens instead of passwords. This approach is discussed in the next section.
Token Authentication
Let us now activate token authentication.
This avoids sending login/passwords in each request, and is much more efficient
for the server because cryptographic password hashing functions are designed
to be very slow. Moreover, you may not want to pay for cpu cycles and have your
users endure additional latency just for that.
Token authentication can be activated explicitely by prepending token to
FSA_AUTH
in acme.conf
:
# update "acme.conf"
FSA_AUTH = ["token", "basic", "param", "none"]
Then we need a token signature secret and a route to create a token.
Edit File acme.conf
to add the secret and delay of your chosing:
# append to "acme.conf"
# Unix 256+ bits random secret in ASCII: head -c 33 /dev/random | base64
FSA_TOKEN_SECRET = "<some-good-and-long-secret-for-token-signature>"
# NOTE: if not set, a random default is used instead
FSA_TOKEN_DELAY = 10.0 # set token expiration to 10 minutes (default is 1 hour)
In a more realistic setting, the token secret would probably not be directly
in the configuration, but passed to it or loaded by it somehow.
Edit File app.py
to add new routes to create a token for the current
user authenticated by a password scheme:
# append to "app.py"
@app.get("/token", authorize="AUTH", auth=["basic", "param"])
def get_token(user: fsa.CurrentUser):
return { "token": app.create_token(user) }, 200
# web application like to POST with authentication parameters to get a token
@app.post("/token", authorize="AUTH", auth="param")
def post_token(user: fsa.CurrentUser):
return { "token": app.create_token(user) }, 201
Then restart and test:
curl -si -X GET -u "acme:$ACME_PASS" http://localhost:5000/token
curl -si -X POST -d username=acme -d password="$ACME_PASS" http://localhost:5000/token
You should see the token as a JSON property in the response.
The default token type is fsa, with a short human-readable format.
Proceed to use the token instead of the login/password to authenticate the user
on a route:
curl -si -X GET -H "Authorization: Bearer <put-the-raw-token-value-here>" \
http://localhost:5000/hello-me # 200
Also append these same tests to test.py
, and run them with pytest
to
achieve 5 passed:
# append to "test.py"
def test_token_authn(client):
acme_param_auth = {"username": "acme", "password": os.environ["ACME_PASS"]}
# GET /token basic
res = client.get("/token", headers=ACME_BASIC)
assert res.status_code == 200 and "token" in res.json
ACME_TOKEN = { "Authorization": f"Bearer {res.json['token']}" }
# GET /hello-me with the token
res = client.get("/hello-me", headers=ACME_TOKEN)
assert res.status_code == 200 and res.json["user"] == "acme"
# GET /token param
res = client.get("/token", data=acme_param_auth)
assert res.status_code == 200 and "token" in res.json
res = client.get("/token", json=acme_param_auth)
assert res.status_code == 200 and "token" in res.json
# POST /token param
res = client.post("/token", data=acme_param_auth)
assert res.status_code == 201 and "token" in res.json
res = client.post("/token", json=acme_param_auth)
assert res.status_code == 201 and "token" in res.json
res = client.post("/token", headers=ACME_BASIC)
assert res.status_code == 401 # no basic auth
Dataclass Parameters
Application front ends are typically developed with JavaScript, thus JSON
(JavaScript Object Notation) is a convenient serialization format to
exchange data with a Python back end.
FlaskSimpleAuth supports data classes for parameters and return values.
Let us install the pydantic
data-structure validation library:
pip install pydantic
Then add data type definitions and an open route to app.py
to compute the age
of Someone in days.
# append to "app.py"
from pydantic.dataclasses import dataclass
import datetime
@dataclass
class Someone:
name: str
born: datetime.date
@dataclass
class Days:
name: str
days: int
@app.get("/days", authorize="OPEN")
def get_days(who: Someone):
age = datetime.datetime.now().date() - who.born
return fsa.jsonify(Days(name=who.name, days=age.days))
This route can be tested directly:
# http parameter
curl -si -X GET -d who='{"name":"Hobbes","born":"2020-07-29"}' http://localhost:5000/days
# json parameter
curl -si -X GET -H "Content-Type: application/json" \
-d '{"who":{"name":"Calvin","born":"1970-03-20"}}' http://localhost:5000/days
# with an invalid date
curl -si -X GET -d who='{"name":"Calvin","born":"unknown"}' http://localhost:5000/days
Then automatically, run with pytest
to achieve 8 passed:
# append to "test.py"
def test_days(client):
res = client.get("/days", data={"who":{"name":"Calvin","born":"1970-03-20"}})
assert res.status_code == 200
assert res.json["name"] == "Calvin" and isinstance(res.json["days"], int)
res = client.get("/days", json={"who":{"name":"Susie","born":"1970-10-14"}})
assert res.status_code == 200
assert res.json["name"] == "Susie" and isinstance(res.json["days"], int)
# invalid data should lead to 400
res = client.get("/days", json={"who":{"name":"Hobbes","born":"not yesterday"}})
assert res.status_code == 400
Note that this also works with standard dataclasses.
Standard Type Hints Parameters
Types hints based on standard types (list
, dict
…) are also supported
through JSON serialization. Let us add a route to report which numbers from a
list are primes with the help of the sympy
package:
pip install sympy
Then add an open route to app.py
to return which integers are prime from a
list of integers:
# append to "app.py"
import sympy
@app.get("/primes", authorize="OPEN")
def get_primes(li: list[int]):
return fsa.jsonify(filter(sympy.isprime, li))
This can be tested directly:
# http parameters
curl -si -X GET -d li=1 -d li=10 -d li=11 http://localhost:5000/primes
# json parameters
curl -si -X GET -H "Content-Type: application/json" \
-d '{"li":[1,10,11,20,21,23]}' http://localhost:5000/primes
# bad parameters should get a 400
curl -si -X GET -d li=prime -d li=time http://localhost:5000/primes
Then automatically, run with pytest
to achieve 9 passed:
# append to "test.py"
def test_primes(client):
res = client.get("/primes?li=7&li=8")
assert res.status_code == 200 and res.json == [7]
res = client.get("/primes", json={"li": [1, 2, 3, 4, 5, 6, 7, 8, 9]})
assert res.status_code == 200 and res.json == [2, 3, 5, 7]
res = client.get("/primes", json={"li": ["odd", "even"]})
assert res.status_code == 400
JSON Parameters
Arbitrary JSON parameters can be declared with special type JsonData
:
# append to "app.py"
@app.get("/len", authorize="OPEN")
def get_len(v: fsa.JsonData):
try:
return {"len": len(v)}
except TypeError:
return {"len": "not available"}
Which it can be tested manually:
curl -si -d 'p={"hi":"w!"}' http://localhost:5000/len # 200: 1
curl -si -d 'p=[1,2]' http://localhost:5000/len # 200: 2
curl -si -d 'p="hi!"' http://localhost:5000/len # 200: 3
curl -si -d 'p=3.14' http://localhost:5000/len # 200: not available
curl -si -d 'p=not-json' http://localhost:5000/len # 400: invalid parameter
curl -si http://localhost:5000/len # 400: missing parameter
And automatically:
# append to "test.py"
def test_len(client):
# working
res = client.get("len", json={"p": {"hi":"w!"}})
assert res.status_code == 200 And res.json["len"] == 1
res = client.get("len", json={"p": ["foo", "bla"]})
assert res.status_code == 200 And res.json["len"] == 2
res = client.get("len", json={"p": "hi!"})
assert res.status_code == 200 And res.json["len"] == 3
res = client.get("len", data={"p": '"hi!"'})
assert res.status_code == 200 And res.json["len"] == 3
res = client.get("len", json={"p": null})
assert res.status_code == 200 And res.json["len"] == "not available"
# errors
res = client.get("len")
assert res.status_code == 400 # missing parameter
res = client.get("len", data={"p": "bad-json"})
assert res.status_code == 400 # invalid parameter value
Further Improvements
Let us edit acme.conf
to activate or change some features.
Errors are shown as text/plain
by default, but this can be changed to JSON
which will be more easily handled by JavaScript clients:
# append to "acme.conf"
FSA_ERROR_RESPONSE = "json:error" # show errors as JSON
You can add minimal password strength requirements:
# append to "acme.conf"
# passwords must contain at least 5 characters
FSA_PASSWORD_LENGTH = 5
# including an upper case letter, a lower case letter, and a digit.
FSA_PASSWORD_RE = [ r"[A-Z]", r"[a-z]", r"[0-9]" ]
After restarting the application, weak passwords are rejected, and error messages as shown as JSON objects:
curl -si -X POST -u "acme:$ACME_PASS" \
-d login="came" -d email="came@acme.org" -d password="C@me" \
http://localhost:5000/user # 400 (pass length)
curl -si -X POST -u "acme:$ACME_PASS" \
-d login="came" -d email="came@acme.org" -d password="Cameleon" \
http://localhost:5000/user # 400 (pass regex)
Also append these same tests to test.py
, and run them with pytest
to
achieve 10 passed:
# append to "test.py"
def test_weak_password(client):
res = client.post("/user", headers=ACME_BASIC,
data={"login": "came", "password": "C@me", "email": "came@acme.org"})
assert res.status_code == 400
assert "too short" in res.json["error"]
res = client.post("/user", headers=ACME_BASIC,
data={"login": "came", "password": "Cameleon", "email": "came@acme.org"})
assert res.status_code == 400
assert "must match" in res.json["error"]
You may want to use standard JWT (JSON Web Token) instead of fsa tokens.
For that, install package dependencies pip install FlaskSimpleAuth[jwt]
and
update the application configuration:
# append to "acme.conf"
FSA_TOKEN_TYPE = "jwt" # default is "fsa"
The authentication configuration can be simplified to the same effect by setting it to password, which stands for both basic and param, and the fact that token and none are added implicitely when the configuration is a scalar:
# update "acme.conf"
FSA_AUTH = "password"
The default behavior is that any authentication scheme can be used on any
route which requires authentication.
Often, an application can be more selective, for instance by requiring
an actual password on the login route and only tokens on the others.
In order to achieve this behavior:
# append to "acme.conf"
FSA_AUTH_DEFAULT = "token" # default authentication scheme
And update route definitions to require some other scheme when needed with
parameter auth="param"
, auth="basic"
, auth="password"
for both, or
auth="none"
for open routes.
Finally, as the debugging level is not useful anymore, it can be
reduced by updating FSA_MODE
setting:
# update in "acme.conf"
FSA_MODE = "dev"
Restart and test the application with these new settings…
Colophon
By following this tutorial, you have built a secure Flask application by taking advantage of features provided by FlaskSimpleAuth: basic, parameter and token authentications, group and object permissions authorizations, and handling data classes.
Note: a tutorial is not the standard way of doing things, it is just a
simplistic and minimal example to demonstrate available features.
You should develop your skills by using tools such as make
and shell
scripting to simplify, automate and speed up your development process.
Also, the above tests with authentications could be simplified with
the FlaskTester package.