Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
52bf45d
Add config for Gunicorn
serhiiur Sep 1, 2025
4aabf66
Add script to run the application
serhiiur Sep 1, 2025
6b84347
Locked uv packages
serhiiur Sep 1, 2025
fa97538
Sync configs for Ruff, Mypy and Pytest
serhiiur Sep 1, 2025
83e2567
Update steps to build the image
serhiiur Sep 1, 2025
bf8c314
Update app config template
serhiiur Sep 1, 2025
f40488e
Add location to host static files
serhiiur Sep 1, 2025
dfb2eec
Update policy for regular users
serhiiur Sep 1, 2025
ebea86f
Add __init__.py
serhiiur Sep 1, 2025
32c8b2f
Update interface to typer
serhiiur Sep 1, 2025
e98f122
Update database migrations file
serhiiur Sep 1, 2025
45e9f70
Changed class for enum-based constatns to inherit
serhiiur Sep 1, 2025
dae00e2
Update list of attributes of the 'UserView'
serhiiur Sep 1, 2025
8952226
Add settings to the main Admin app
serhiiur Sep 1, 2025
b659dcf
Set default user role
serhiiur Sep 1, 2025
a39d9e9
Update clients for E2E tests
serhiiur Sep 1, 2025
19a77f3
Update clients for E2E tests
serhiiur Sep 1, 2025
9688fd1
Add separate clients with different roles for E2E tests
serhiiur Sep 1, 2025
6244de9
Update integration tests
serhiiur Sep 1, 2025
65bb8e4
Add healthchecks. Update service dependencies
serhiiur Sep 1, 2025
8844bb4
Remove unique index from primary key attribute
serhiiur Sep 1, 2025
fd2039d
Remove unused files
serhiiur Sep 1, 2025
61d4e8e
Create custom base exception class
serhiiur Sep 1, 2025
b64d09b
Add configuration for logger
serhiiur Sep 1, 2025
8f51aed
Set separate settings for FastAPI and Admin apps, logging and CORS
serhiiur Sep 1, 2025
1f8b866
Update declaration of the db engine and base declarative class
serhiiur Sep 1, 2025
7ad062a
Add type aliases for db and redis dependencies
serhiiur Sep 1, 2025
1479e12
Update lifespan events and configuration of CORS
serhiiur Sep 1, 2025
1a7f54d
Update healthcheck endpoint
serhiiur Sep 1, 2025
3e8d465
Add error handlers for database and data validation errors
serhiiur Sep 1, 2025
9040d7d
Move middleware to handle OAuth flow
serhiiur Sep 1, 2025
e21ab41
Update User model attributes and method to create a new user
serhiiur Sep 1, 2025
8a1059b
Update validators. Add description to schema attributes
serhiiur Sep 1, 2025
c53ba36
Update dependencies to validate tokens
serhiiur Sep 1, 2025
84c5d3d
Update cache settings for 'get_token_payload' function
serhiiur Sep 1, 2025
bee95e3
Move base exception class to 'main.exceptions'
serhiiur Sep 1, 2025
7c0c310
Update docstrings. Fix Ruff and Mypy errors
serhiiur Sep 1, 2025
a9c3a08
Remove unused files
serhiiur Sep 1, 2025
3447267
Update .gitignore
serhiiur Sep 1, 2025
429b883
Update config for Ruff and Pytest
serhiiur Sep 1, 2025
d9822be
Fix Mypy error
serhiiur Sep 1, 2025
3a2a80f
Update steps to install python and dependencies
serhiiur Sep 1, 2025
04f7e0b
Fix Mypy and Ruff errors
serhiiur Sep 1, 2025
2a29d54
Update params for gunicorn
serhiiur Sep 2, 2025
c3c8a4c
Add 'docs_url' param to the FastAPI settings
serhiiur Sep 2, 2025
dcd2343
Remove step to copy config for gunicorn
serhiiur Sep 2, 2025
062cd94
Replace '/docs' location with '/'
serhiiur Sep 2, 2025
af4babe
Remove config for gunicorn
serhiiur Sep 2, 2025
ddafd47
Inline definition of 'authorized_client' fixture
serhiiur Sep 6, 2025
4178322
Add 'type_annotation_map' for 'Base' class
serhiiur Sep 6, 2025
c3b1085
Remove timezone from datetime-based attributes
serhiiur Sep 6, 2025
987a8ef
Inline definition of 'decode_token' function
serhiiur Sep 7, 2025
159cd38
Add error handler for uncaught errors
serhiiur Sep 9, 2025
d90d78f
Add error handler for uncaught errors to the app
serhiiur Sep 9, 2025
977354b
Fix missing 'app' argument in the lifespan function
serhiiur Sep 9, 2025
e0467f7
Fix codestyle errors
serhiiur Sep 9, 2025
aa7816b
Add asgi-lifespan package
serhiiur Sep 10, 2025
e770ae9
Update 'client' fixture to use 'LifespanManager'
serhiiur Sep 10, 2025
81fb7e6
Update 'lifespan' function to check objects in the state
serhiiur Sep 10, 2025
fdf5ddf
Add test to check existence of the users page in the admin
serhiiur Sep 10, 2025
b459583
Update 'test_inactive_user_login' test case
serhiiur Sep 10, 2025
2456259
Add separate file to configure redis
serhiiur Sep 10, 2025
7f9f69c
Update README.md
serhiiur Sep 23, 2025
520a487
Add image to display request lifecycle
serhiiur Sep 23, 2025
4cbccbd
Update README.md
serhiiur Sep 23, 2025
1256e28
Update request_lifecycle.png
serhiiur Sep 24, 2025
0a04165
Update README.md
serhiiur Sep 24, 2025
3a5ae8d
Update README.md
serhiiur Oct 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
APP_HOST=auth
APP_HOST=app
APP_PORT=8000

DEBUG=1
ADMIN_DEBUG=1
GUNICORN_WORKERS=5
GUNICORN_THREADS=2
GUNICORN_WORKERS=2

POSTGRES_USER=user
POSTGRES_PASSWORD=123456
POSTGRES_PASSWORD=12345
POSTGRES_DB=user
POSTGRES_HOST=db
POSTGRES_PORT=5432
Expand All @@ -16,7 +14,7 @@ DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGR
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_USER=default
REDIS_PASSWORD=secret123
REDIS_PASSWORD=123456
REDIS_URL=redis://${REDIS_USER}:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}

NGINX_PORT=80
Expand Down
33 changes: 17 additions & 16 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
@@ -1,39 +1,40 @@
name: code checks
name: Code Checks

on:
push:
branches: [ main ]
paths: ['src/**']
pull_request:
branches: [ main ]

jobs:
checks:
name: Test Code
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v3
uses: actions/setup-python@v4
with:
python-version: 3.12
python-version: "3.12"

- name: Install python packages
run: |
pip install --upgrade pip
pip install setuptools poetry
poetry install --no-root
- name: Install uv
uses: astral-sh/setup-uv@v1
with:
version: "0.7.13"

- name: Install dependencies
run: uv sync --locked --all-extras --group test

- name: Running Ruff
run: poetry run ruff check
- name: Run Ruff
run: uv run ruff check

- name: Running Mypy
run: poetry run mypy .
- name: Run Mypy
run: uv run mypy

- name: Running Pytest with Coverage
run: poetry run pytest --cov
- name: Run Pytest with Coverage
run: uv run pytest --cov

- name: Creating coverage folder
run: mkdir -p coverage
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ venv
__pycache__
.pytest_cache
.env
.coverage
notes.txt
test.db
39 changes: 10 additions & 29 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,32 +1,13 @@
FROM python:3.12.4-slim AS builder

ARG DEV=false

COPY pyproject.toml poetry.lock /

RUN pip install poetry && \
POETRY_CMD="poetry export -f requirements.txt --output requirements.txt --without-hashes"; \
if [ "$DEV" = "true" ]; then \
POETRY_CMD="$POETRY_CMD --with dev"; \
fi; \
$POETRY_CMD && \
pip wheel --no-cache-dir --no-deps --wheel-dir /wheels -r requirements.txt


FROM python:3.12.4-slim

FROM ghcr.io/astral-sh/uv:python3.12-alpine
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONIOENCODING=utf-8 \
PYTHONPATH=/app

WORKDIR /app

COPY --from=builder /wheels /wheels
COPY ./src .

RUN chmod +x ./scripts/run.sh && \
pip install --no-cache /wheels/* && \
rm -rf /wheels

CMD ["./scripts/run.sh"]
PATH="/src/.venv/bin:$PATH" \
PYTHONPATH="/src"
WORKDIR /src
COPY ./pyproject.toml ./uv.lock ./run.sh ./
RUN apk add --no-cache curl && \
uv sync --locked && \
chmod +x ./run.sh
COPY ./src ./
CMD ["./run.sh"]
110 changes: 94 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,35 +1,113 @@
![Pytest](https://github.com/pypa/hatch/actions/workflows/test.yml/badge.svg)
![Pytest](https://github.com/pypa/hatch/actions/workflows/protected.yml/badge.svg)
![Pytest Coverage](https://github.com/srg12354/fastapi-auth-api/blob/coverage-badge/coverage.svg)
[![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
[![Linter Ruff](https://img.shields.io/badge/Linter-Ruff-brightgreen)](https://github.com/charliermarsh/ruff)


## About
Inspired by FastAPI's [OAuth2 with Password (and hashing), Bearer with JWT tokens](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/), this project represents a simple cookies-based role-based authentication service using JWT tokens, built with FastAPI and managed by Nginx's [Auth Sub Request](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-subrequest-authentication/) module to verify users access to the protected resources of the API.

## Usage

Build production image:
```bash
docker-compose build .
## Features
- Access and refresh JWT tokens stored in secure cookies
- Role-based access control
- Automatic tokens refreshment and invalidation
- Authentication based on Nginx's subrequest module
- Admin UI to manage users and roles
- CLI for creating users


## How it works

<p align="center">
<img src="assets/images/request_lifecycle.png" alt="Request Flow" width="800" />
</p>


## How to protect an endpoint
Only 2 steps required to protect an endpoint, and grant access to the endpoint only to users with specific roles. For example, let's say you have an endpoint named `/protected` that you want to protect it from the public access:

### Step 1. Add a location block for the target to [Nginx config](proxy/default.conf.tpl):

```nginx
location = /protected {
include /etc/nginx/snippets/auth_subrequest.conf;
proxy_pass http://auth_api/protected;
}
```

Build development image:
The most important part in the location block is the inclusion of the `auth_subrequest.conf` snippet, which contains the configuration for Nginx's subrequest authentication module.


### Step 2. Grant access to the endpoint only to users with a specific role.

For this, you simple need to insert the endpoint into the `locations` attribute of the specified role in [policy.json](src/policy.json) file. For example, let's say that access to the `/protected` endpoint should be granted only to users with the `moderator` role:

```json
"moderator": {
"locations": [
"/protected"
]
}
```

As a result, after authentication and authorization process, Nginx will automatically send an internal subrequest to the API for each request to the `/protected` endpoint, verifying user's JWT tokens and role. If the user doesn't have access to the protected endpoint, the API will respond with the `403 Forbidden` status code.


## System Requirements

* Python 3.12
* UV package manager
* Docker and Docker Compose plugin


## Configuration
There are a few configuration files that can be modified to customize the behavior of the application:

* [proxy/default.conf.tpl](proxy/default.conf.tpl). This is the configuration file for Nginx. In this file you declare the endpoints (locations) that you want to protect in your API using Nginx's subrequest module.
* [src/policy.json](src/policy.json). Access control policy file. This is a simple JSON file that defines the roles and their associated permissions (i.e., which endpoints they can access).
* **.env**. File containing environment variables for configuring the application. It provides settings for:
* Main FastAPI application
* PostgreSQL for storing users data
* Redis for storing information about authentication tokens
* JWT tokens
* Nginx server
* Other settings

**Note**: if the **.env** file is missing, create it by copying the **.env.example** file and modifying the values as needed.


## Deployment

Once the configuration files are set up, you can deploy the application using Docker Compose:

```bash
docker-compose build --build-arg DEV=true auth
docker compose up -d
```

Run app:
This will start the following services:
* app - FastAPI application;
* db - PostgreSQL database to store users data;
* redis - Redis database to store authentication tokens data;
* nginx - Nginx for proxying API requests and handling authentication using the *Auth Subrequest* module.


## Usage

Once all services are up and running, you can register the first user via CLI.

**Note**: use the CLI [script](src/scripts/create_user.py) below to register a user with a specified role, such as *admin* or *moderator*.

```bash
docker-compose up -d
docker compose exec app python scripts/create_user.py
```

After creating the user, navigate to `http://localhost/` to access the Swagger UI documentation of the API. From there, you can use the `/login` endpoint to authenticate and obtain a pair of JWT tokens that will be stored in the browser's cookies. You can then access protected endpoints based on the user's role and the corresponding [policy](src/policy.json).


## Useful Links:
## References:
- [OAuth2 with Password (and hashing), Bearer with JWT tokens¶](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/)
- [FastAPI JWT Auth](https://indominusbyte.github.io/fastapi-jwt-auth/)
- [Nginx. Authentication Based on Subrequest Result](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-subrequest-authentication/)
- [SQLAlchemy 2.0 Asynchronous I/O (asyncio)](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html)
- [SQLAlchemy 2.0 Table Configuration with Declarative](https://docs.sqlalchemy.org/en/20/orm/declarative_tables.html)
- [Github Actions. Pytest Coverage Comment](https://github.com/marketplace/actions/pytest-coverage-comment)
- [Github. FakeRedis](https://fakeredis.readthedocs.io/en/latest)
- [Redis Asyncio Examples](https://redis-py.readthedocs.io/en/stable/examples/asyncio_examples.html)
- [The mypy configuration file](https://mypy.readthedocs.io/en/stable/config_file.html)
- [JSON Web Token (JWT) Debugger](https://www.jwt.io/)

Binary file added assets/images/request_lifecycle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 31 additions & 13 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
version: '3.8'

services:
auth:
app:
build: .
container_name: auth
restart: on-failure
container_name: app
restart: always
environment:
- APP_HOST=${APP_HOST}
- APP_PORT=${APP_PORT}
Expand All @@ -15,30 +13,47 @@ services:
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
- REDIS_URL=${REDIS_URL}
- SERVER_DOMAIN=${SERVER_DOMAIN}
volumes:
- ./src:/app
depends_on:
- db
- redis
db:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- static_data:/src/.venv/lib/python3.12/site-packages/sqladmin/statics
healthcheck:
test: ["CMD-SHELL", "curl -f http://${APP_HOST}:${APP_PORT}/health || exit 1"]
interval: 5s
timeout: 5s
retries: 5

db:
image: postgres:16.3-alpine
container_name: db
restart: on-failure
restart: always
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 10s
retries: 5

redis:
image: redis:7.2-alpine
container_name: redis
restart: on-failure
restart: always
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD} ping"]
interval: 5s
timeout: 10s
retries: 5

nginx:
build:
Expand All @@ -51,12 +66,15 @@ services:
- NGINX_PORT=${NGINX_PORT}
- NGINX_SERVER_HOST=${NGINX_SERVER_HOST}
depends_on:
- auth
app:
condition: service_healthy
ports:
- ${NGINX_PORT}:${NGINX_PORT}
volumes:
- ./proxy/snippets:/etc/nginx/snippets
- static_data:/api/statics

volumes:
pg_data:
redis_data:
redis_data:
static_data:
Loading