There are many ways we can secure an endpoint in FastAPI. As a first approach we explore HTTP basic authentication, a not so secure way but it allows us to get a first glimpse into the different parts of FastAPI that we need to protect an endpoint.
This post is part of my journey to learn Python. You find the code for this post in my PythonFriday repository on GitHub.
HTTP basic authentication?
For this authentication method the browser sends the username and password as part of the header to the application. While we cannot see the password in clear text, it is only encoded as a Base64 string and completely unprotected:
Authorization: Basic c3RhbmxleTpzZWNyZXQ=
When we use FastAPI in development mode, we use HTTP and not HTTPS, what allows everyone to scan the traffic and read the password. With HTTPS that will be less of a problem, but still not up to the state of the art. We will explore better approaches in the next posts.
Test the authentication
As with the posts in the past, we first start with a few tests to define what behaviour we expect from our API. I created a new project in the folder basic_auth and it has no connection to the to-do list API we used so far with FastAPI. Do not forget to add an empty __init__.py next to the test file.
As always, write one test, then write the implementation. Repeat until you have the complete feature. Here are the 4 basic tests we need to check if the endpoint is protected:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
import base64 from fastapi.testclient import TestClient from .main import app client = TestClient(app) def test_without_login_gets_401_unauthorized(): response = client.get("/users/me") assert response.status_code == 401 def test_can_access_endpoint_after_login(): credentials = base64.b64encode(b"stanley:secret").decode("utf-8") response = client.get("/users/me", headers={"Authorization": "Basic " + credentials}) assert response.status_code == 200 assert response.json() == {'firstName': 'Stanley', 'lastName': 'Jobson', 'user': 'stanley'} def test_wrong_password_gives_error(): credentials = base64.b64encode(b"stanley:password").decode("utf-8") response = client.get("/users/me", headers={"Authorization": "Basic " + credentials}) assert response.status_code == 401 assert response.json()["detail"] == "Incorrect username or password" def test_wrong_username_gives_error(): credentials = base64.b64encode(b"MMike:password").decode("utf-8") response = client.get("/users/me", headers={"Authorization": "Basic " + credentials}) assert response.status_code == 401 assert response.json()["detail"] == "Incorrect username or password" # pytest --durations=0 .\test_basic_auth.py # 0.22s call test_basic_auth.py::test_can_access_endpoint_after_login # 0.22s call test_basic_auth.py::test_wrong_password_gives_error # 0.22s call test_basic_auth.py::test_wrong_username_gives_error |
Create the FastAPI application
We can now create a new FastAPI application in the main.py file and add these parts:
- The users dictionary is our mock data store and contains 2 users.
- The get_current_username() method is the bridge between the HTTP Basic authentication and our user store. It will check if the password match and throw an exception if this is not the case.
- To prevent timing attacks, we go through the process of verifying the password even when there is no user for the given username. That little extra work gives us near identical runtimes for all tests that check credentials (you can verify that with
pytest --durations=0
) - The read_current_user() method gets the injected user name and reads the data we have about that user.
The example here is the HTTP Basic Auth example from the official documentation, extended with Bcrypt and offers a second user:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
# Example from https://fastapi.tiangolo.com/advanced/security/http-basic-auth/ # extended with a second user, a users dictionary and BCrypt from typing import Annotated import bcrypt from fastapi import Depends, FastAPI, HTTPException, status from fastapi.security import HTTPBasic, HTTPBasicCredentials app = FastAPI() users = { "stanley" : { "first_name": "Stanley", "last_name": "Jobson", "password_hash": b'$2b$12$HIbxs5kbjinDEUzbQJYqpeTp.GxRgy4m8hdQM4JnSunGQ6VaY5Ld6', # secret }, "mike" : { "first_name": "Mike", "last_name": "Doe", "password_hash": b'$2b$12$JAM3vz8gEZDeNSDAILiaReTmvoNM5EEP33Elhq5fCoTgno4SxfqKO', # password } } security = HTTPBasic() def get_current_username(credentials: Annotated[HTTPBasicCredentials, Depends(security)]): if credentials.username in users: user = users[credentials.username] pw_hash = user["password_hash"] else: user = users[max(users.keys())] # only done to get same runtime pw_hash = b'$2b$12$bpKSBbvimpx3M357O0Oq8.MMquDtXS/e8jHU4X.tyzQl/qmnKH5uS' if not (bcrypt.checkpw(credentials.password.encode("utf-8"), pw_hash)): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", headers={"WWW-Authenticate": "Basic"}, ) return credentials.username @app.get("/users/me") def read_current_user(username: Annotated[str, Depends(get_current_username)]): details = users[username] return {"user": username, "firstName": details["first_name"], "lastName": details["last_name"], } |
We can now run our tests and they all should pass:
1 |
pytest |
Access the endpoint in the browser
First, we must start our API with this command:
1 |
uvicorn main:app --reload |
If we open the endpoint in the browser, we get the HTTP Basic Auth message from the server that lets the browser open a form to enter the username and the password:
We can enter stanley and secret to get access to the data:
Access the endpoint in the documentation
We can go to the OpenAPI documentation in /docs and use the button “Authorize” at the top of the page to enter our credentials:
We can enter mike and password to get the login for the other user. In our endpoint documentation we can use the “Try it out” button and then click on execute to get the data we have about the user mike:
Next
We can now use HTTP basic authentication to protect our endpoint from unauthorised people. As explained above, the password is only encoded and not protected as long as we use HTTP. We fix that later when it comes to the deployment.
Next week we use OAuth2 and JWT tokens to better protect our API.
1 thought on “Python Friday #228: HTTP Basic Authentication in FastAPI”