Skip to content

Commit f8655d1

Browse files
committed
feat: add jwt authorization
1 parent 34c27d6 commit f8655d1

12 files changed

Lines changed: 466 additions & 33 deletions

File tree

Dockerfile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@ RUN pip install --upgrade pip && pip install -e '.[dev]'
1414
# Copy application code
1515
COPY . .
1616

17+
# Required env vars
18+
ENV AUTH_JWT_SECRET_KEY=""
19+
1720
# Expose port
1821
EXPOSE 8000
1922

2023
# Run the application
21-
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
24+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ test:
2525
test-unit:
2626
@echo "Running unit tests..."
2727
docker build -t python-fastapi-example-oms-app:latest .
28-
docker run --rm --tty python-fastapi-example-oms-app:latest python -m pytest tests/unit/ -v
28+
docker run --rm --tty -e AUTH_JWT_SECRET_KEY=docker-unit-tests-key python-fastapi-example-oms-app:latest python -m pytest tests/unit/ -v
2929

3030
test-integration:
3131
$(MAKE) clean

app/core/auth.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import os
2+
3+
import jwt
4+
from datetime import datetime, timedelta, timezone
5+
6+
from fastapi import Header, HTTPException, status
7+
8+
ALGORITHM = "HS256"
9+
ACCESS_TOKEN_EXPIRE_MINUTES = 30
10+
11+
12+
def _get_secret_key() -> str:
13+
"""Return the JWT secret key from the environment variable."""
14+
return os.environ["AUTH_JWT_SECRET_KEY"]
15+
16+
17+
def create_access_token(username: str) -> str:
18+
"""Create a JWT access token for the given username.
19+
20+
The token includes a 'sub' claim set to the username and an 'exp' claim
21+
set to now + ACCESS_TOKEN_EXPIRE_MINUTES.
22+
23+
Args:
24+
username: The username to encode in the token's 'sub' claim.
25+
26+
Returns:
27+
A signed JWT string using HS256.
28+
"""
29+
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
30+
payload = {"sub": username, "exp": expire}
31+
return jwt.encode(payload, _get_secret_key(), algorithm=ALGORITHM)
32+
33+
34+
def decode_access_token(token: str) -> str:
35+
"""Decode and validate a JWT access token, returning the username.
36+
37+
Raises HTTPException(401) for expired or invalid tokens with appropriate
38+
error messages and WWW-Authenticate headers.
39+
40+
Args:
41+
token: The JWT string to decode.
42+
43+
Returns:
44+
The username from the token's 'sub' claim.
45+
46+
Raises:
47+
HTTPException: 401 if the token is expired or invalid.
48+
"""
49+
try:
50+
payload = jwt.decode(token, _get_secret_key(), algorithms=[ALGORITHM])
51+
return payload.get("sub")
52+
except jwt.ExpiredSignatureError:
53+
raise HTTPException(
54+
status_code=status.HTTP_401_UNAUTHORIZED,
55+
detail="Token has expired",
56+
headers={"WWW-Authenticate": "Bearer"},
57+
)
58+
except jwt.InvalidTokenError:
59+
raise HTTPException(
60+
status_code=status.HTTP_401_UNAUTHORIZED,
61+
detail="Invalid token",
62+
headers={"WWW-Authenticate": "Bearer"},
63+
)
64+
65+
66+
async def get_current_user(authorization: str | None = Header(default=None)):
67+
"""Extract and validate Bearer token. Returns username."""
68+
if authorization is None:
69+
raise HTTPException(
70+
status_code=status.HTTP_401_UNAUTHORIZED,
71+
detail="Authorization header required",
72+
headers={"WWW-Authenticate": "Bearer"},
73+
)
74+
prefix, _, token = authorization.partition(" ")
75+
if prefix.lower() != "bearer" or not token:
76+
raise HTTPException(
77+
status_code=status.HTTP_401_UNAUTHORIZED,
78+
detail="Invalid authorization header",
79+
headers={"WWW-Authenticate": "Bearer"},
80+
)
81+
return decode_access_token(token)

app/core/config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
11
import os
22

3+
34
class Settings:
45
app_name: str = "OMS API"
56
debug: bool = True
67

8+
def __init__(self) -> None:
9+
self.auth_jwt_secret_key = self._load_auth_jwt_secret_key()
10+
11+
@staticmethod
12+
def _load_auth_jwt_secret_key() -> str:
13+
key = os.environ.get("AUTH_JWT_SECRET_KEY")
14+
if not key:
15+
raise ValueError(
16+
"AUTH_JWT_SECRET_KEY environment variable is required and must not be empty"
17+
)
18+
return key
19+
20+
721
settings = Settings()

app/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from fastapi import FastAPI
22
from app.api.router import api_router
33

4+
from app.core.config import settings # imported for startup validation
5+
46
app = FastAPI()
57

68
app.include_router(api_router)

app/modules/orders/routes.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from app.db.session import get_db
55
from . import service, schemas, repository
6+
from app.core.auth import get_current_user
67

78
router = APIRouter()
89

@@ -12,25 +13,41 @@ def get_orders_repository(db: Session = Depends(get_db)):
1213

1314

1415
@router.post("/", response_model=schemas.OrderRead)
15-
def create_order(payload: schemas.OrderCreate, repo: repository.OrderRepository = Depends(get_orders_repository)):
16+
def create_order(
17+
payload: schemas.OrderCreate,
18+
repo: repository.OrderRepository = Depends(get_orders_repository),
19+
_user: str = Depends(get_current_user),
20+
):
1621
return service.create_order(repo, payload.item)
1722

1823

1924
@router.get("/{order_id}", response_model=schemas.OrderRead)
20-
def get_order(order_id: int, repo: repository.OrderRepository = Depends(get_orders_repository)):
25+
def get_order(
26+
order_id: int,
27+
repo: repository.OrderRepository = Depends(get_orders_repository),
28+
_user: str = Depends(get_current_user),
29+
):
2130
try:
2231
return service.get_order(repo, order_id)
2332
except ValueError:
2433
raise HTTPException(status_code=404, detail="Order not found")
2534

2635

2736
@router.get("/", response_model=list[schemas.OrderRead])
28-
def list_orders(repo: repository.OrderRepository = Depends(get_orders_repository)):
37+
def list_orders(
38+
repo: repository.OrderRepository = Depends(get_orders_repository),
39+
_user: str = Depends(get_current_user),
40+
):
2941
return service.list_orders(repo)
3042

3143

3244
@router.put("/{order_id}", response_model=schemas.OrderRead)
33-
def update_order(order_id: int, payload: schemas.OrderUpdate, repo: repository.OrderRepository = Depends(get_orders_repository)):
45+
def update_order(
46+
order_id: int,
47+
payload: schemas.OrderUpdate,
48+
repo: repository.OrderRepository = Depends(get_orders_repository),
49+
_user: str = Depends(get_current_user),
50+
):
3451
try:
3552
updates = {k: v for k, v in payload.model_dump(exclude_unset=True).items()}
3653
return service.update_order(repo, order_id, **updates)
@@ -39,7 +56,11 @@ def update_order(order_id: int, payload: schemas.OrderUpdate, repo: repository.O
3956

4057

4158
@router.delete("/{order_id}", status_code=204)
42-
def delete_order(order_id: int, repo: repository.OrderRepository = Depends(get_orders_repository)):
59+
def delete_order(
60+
order_id: int,
61+
repo: repository.OrderRepository = Depends(get_orders_repository),
62+
_user: str = Depends(get_current_user),
63+
):
4364
try:
4465
service.delete_order(repo, order_id)
4566
except ValueError:

app/modules/users/routes.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from app.db.session import get_db
55
from . import service, schemas, repository
6+
from app.core.auth import create_access_token
67

78
router = APIRouter()
89

@@ -27,7 +28,8 @@ def register_user(payload: schemas.UserCreate, repo: repository.UserRepository =
2728
@router.post("/login", response_model=schemas.UserToken)
2829
def login_user(payload: schemas.UserAuthenticate, repo: repository.UserRepository = Depends(get_users_repository)):
2930
user = service.authenticate_user(repo, payload.username, payload.password)
31+
token = create_access_token(user.username)
3032
return schemas.UserToken(
31-
access_token=f"fake-jwt-token-for-{user.username}",
33+
access_token=token,
3234
token_type="bearer"
3335
)

docker-compose.yml

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ services:
2121
build:
2222
context: .
2323
dockerfile: Dockerfile
24-
environment:
25-
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/app
26-
DEBUG: "true"
2724
depends_on:
2825
postgres:
2926
condition: service_healthy
27+
environment:
28+
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/app
29+
DEBUG: "true"
3030
command: alembic -c alembic.ini upgrade head
3131
networks:
3232
- app_network
@@ -35,14 +35,15 @@ services:
3535
build:
3636
context: .
3737
dockerfile: Dockerfile
38+
depends_on:
39+
postgres:
40+
condition: service_healthy
3841
ports:
3942
- "8000:8000"
4043
environment:
4144
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/app
4245
DEBUG: "true"
43-
depends_on:
44-
postgres:
45-
condition: service_healthy
46+
AUTH_JWT_SECRET_KEY: test-secret-key-for-local-dev-only
4647
volumes:
4748
- .:/app
4849
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ dependencies = [
99
"pydantic==2.13.3",
1010
"alembic==1.18.4",
1111
"bcrypt==4.0.1",
12+
"PyJWT==2.10.1",
1213
]
1314

1415
[project.optional-dependencies]
1516
dev = [
1617
"pytest==9.0.3",
18+
"pytest-asyncio==1.3.0",
1719
"httpx==0.28.1",
1820
]
1921

@@ -25,3 +27,4 @@ where = ["."]
2527

2628
[tool.pytest.ini_options]
2729
pythonpath = ["."]
30+
asyncio_mode = "auto"

0 commit comments

Comments
 (0)