Merge pull request #100 from grillazz/99-jwt-auth-sample

99 jwt auth sample
This commit is contained in:
Jakub Miazek 2023-07-24 19:30:53 +02:00 committed by GitHub
commit 9622a472d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 979 additions and 416 deletions

12
.env
View File

@ -8,5 +8,13 @@ SQL_USER=user
SQL_PASS=secret
SQL_URL=postgresql+asyncpg://${SQL_USER}:${SQL_PASS}@${SQL_HOST}/${SQL_DB}
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DB=2
REDIS_URL="redis://${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB}"
JWT_EXPIRE=3600
JWT_ALGORITHM=HS256

View File

@ -24,8 +24,19 @@ jobs:
POSTGRES_PASSWORD: secret
PGPASSWORD: secret
SQL_URL: postgresql+asyncpg://app-user:secret@localhost:5432/testdb
FERNET_KEY: Ms1HSn513x0_4WWFBQ3hYPDGAHpKH_pIseC5WwqyO7M=
REDIS_HOST: 127.0.0.1
REDIS_PORT: 6379
REDIS_DB: 2
REDIS_URL: redis://127.0.0.1:6379/2
JWT_EXPIRE: 3600
JWT_ALGORITHM: HS256
services:
redis:
image: redis:latest
ports:
- 6379:6379
sqldb:
image: postgres:14
env:

View File

@ -1,2 +1,2 @@
POSTGRES_PASSWORD=secret
SECRET_KEY=key
FERNET_KEY=Ms1HSn513x0_4WWFBQ3hYPDGAHpKH_pIseC5WwqyO7M=

View File

@ -71,17 +71,18 @@ Below steps were done to integrate [rich](https://github.com/Textualize/rich) in
![sample-logs-with-rich](/static/logz.png)
Hope you enjoy it.
### User authentication with JWT and Redis as token storage :lock: :key:
#### Generate Fernet key for storing password in db
```python
In [1]: from cryptography.fernet import Fernet
In [2]: Fernet.generate_key()
Out[2]: b'Ms1HSn513x0_4WWFBQ3hYPDGAHpKH_pIseC5WwqyO7M='
```
Save the key in .secrets as FERNET_KEY
### Change Log
- 4 JUN 2022 alembic migrations added to project
- 6 JUN 2022 added initial dataset for shakespeare models
- 3 OCT 2022 poetry added to project
- 12 NOV 2022 ruff implemented to project as linting tool
- 14 FEB 2023 bump project to Python 3.11
- 10 APR 2023 implement logging with rich
- 28 APR 2023 Rainbow logs with rich :rainbow:
- 7 JUL 2023 migrate to pydantic 2.0
### Local development with poetry
@ -92,3 +93,17 @@ pyenv install 3.11 && pyenv local 3.11
poetry install
```
Hope you enjoy it.
### Change Log
- 4 JUN 2022 alembic migrations added to project
- 6 JUN 2022 added initial dataset for shakespeare models
- 3 OCT 2022 poetry added to project
- 12 NOV 2022 ruff implemented to project as linting tool
- 14 FEB 2023 bump project to Python 3.11
- 10 APR 2023 implement logging with rich
- 28 APR 2023 Rainbow logs with rich :rainbow:
- 7 JUL 2023 migrate to pydantic 2.0 :fast_forward:
- 25 JUL 2023 add user authentication with JWT and Redis as token storage :lock: :key:

View File

@ -31,7 +31,7 @@ async def run_migrations_online():
and associate a connection with the context.
"""
connectable = create_async_engine(settings.asyncpg_url, future=True)
connectable = create_async_engine(settings.asyncpg_url.unicode_string(), future=True)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)

View File

@ -0,0 +1,40 @@
"""user auth
Revision ID: 2dcc708f88f8
Revises: 0d1ee3949d21
Create Date: 2023-07-22 12:19:28.780926
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2dcc708f88f8'
down_revision = '0d1ee3949d21'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user',
sa.Column('uuid', sa.UUID(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('first_name', sa.String(), nullable=False),
sa.Column('last_name', sa.String(), nullable=False),
sa.Column('password', sa.LargeBinary(), nullable=False),
sa.PrimaryKeyConstraint('uuid'),
sa.UniqueConstraint('uuid')
)
op.create_unique_constraint(None, 'nonsense', ['name'], schema='happy_hog')
op.create_unique_constraint(None, 'stuff', ['name'], schema='happy_hog')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'stuff', schema='happy_hog', type_='unique')
op.drop_constraint(None, 'nonsense', schema='happy_hog', type_='unique')
op.drop_table('user')
# ### end Alembic commands ###

16
app/api/health.py Normal file
View File

@ -0,0 +1,16 @@
import logging
from fastapi import APIRouter, status, Request
router = APIRouter()
@router.get("/redis", status_code=status.HTTP_200_OK)
async def redis_check(request: Request):
_redis = await request.app.state.redis
_info = None
try:
_info = await _redis.info()
except Exception as e:
logging.error(f"Redis error: {e}")
return _info

34
app/api/user.py Normal file
View File

@ -0,0 +1,34 @@
from fastapi import APIRouter, Depends, status, Request, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.schemas.user import UserSchema, UserResponse, UserLogin, TokenResponse
from app.services.auth import create_access_token
router = APIRouter(prefix="/v1/user")
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=UserResponse)
async def create_user(payload: UserSchema, request: Request, db_session: AsyncSession = Depends(get_db)):
_user: User = User(**payload.model_dump())
await _user.save(db_session)
# TODO: add refresh token
_user.access_token = await create_access_token(_user, request)
return _user
@router.post("/token", status_code=status.HTTP_201_CREATED, response_model=TokenResponse)
async def get_token_for_user(user: UserLogin, request: Request, db_session: AsyncSession = Depends(get_db)):
_user: User = await User.find(db_session, [User.email == user.email])
# TODO: out exception handling to external module
if not _user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if not _user.check_password(user.password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Password is incorrect")
# TODO: add refresh token
_token = await create_access_token(_user, request)
return {"access_token": _token, "token_type": "bearer"}

View File

@ -1,12 +1,16 @@
import os
from functools import lru_cache
from pydantic import PostgresDsn
from pydantic import PostgresDsn, RedisDsn
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
asyncpg_url: PostgresDsn = os.getenv("SQL_URL")
secret_key: str = os.getenv("FERNET_KEY")
redis_url: RedisDsn = os.getenv("REDIS_URL")
jwt_algorithm: str = os.getenv("JWT_ALGORITHM")
jwt_expire: int = os.getenv("JWT_EXPIRE")
@lru_cache

View File

@ -1,24 +1,35 @@
from fastapi import FastAPI
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
from app.api.nonsense import router as nonsense_router
from app.api.shakespeare import router as shakespeare_router
from app.api.stuff import router as stuff_router
from app.utils.logging import AppLogger
from app.api.user import router as user_router
from app.api.health import router as health_router
from app.redis import get_redis
from app.services.auth import AuthBearer
logger = AppLogger.__call__().get_logger()
app = FastAPI(title="Stuff And Nonsense API", version="0.5")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Load the redis connection
app.state.redis = await get_redis()
yield
# close redis connection and release the resources
app.state.redis.close()
app = FastAPI(title="Stuff And Nonsense API", version="0.6", lifespan=lifespan)
app.include_router(stuff_router)
app.include_router(nonsense_router)
app.include_router(shakespeare_router)
app.include_router(user_router)
@app.on_event("startup")
async def startup_event():
logger.info("Starting up...")
@app.on_event("shutdown")
async def shutdown_event():
logger.info("Shutting down...")
app.include_router(health_router, prefix="/v1/public/health", tags=["Health, Public"])
app.include_router(health_router, prefix="/v1/health", tags=["Health, Bearer"], dependencies=[Depends(AuthBearer())])

View File

@ -2,3 +2,4 @@
from app.models.nonsense import * # noqa
from app.models.shakespeare import * # noqa
from app.models.stuff import * # noqa
from app.models.user import * # noqa

50
app/models/user.py Normal file
View File

@ -0,0 +1,50 @@
import uuid
from typing import Any
from cryptography.fernet import Fernet
from sqlalchemy import Column, String, LargeBinary, select
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app import config
from app.models.base import Base
global_settings = config.get_settings()
cipher_suite = Fernet(global_settings.secret_key)
class User(Base): # type: ignore
uuid = Column(
UUID(as_uuid=True),
unique=True,
default=uuid.uuid4,
primary_key=True,
)
email = Column(String, nullable=False)
first_name = Column(String, nullable=False)
last_name = Column(String, nullable=False)
_password = Column("password", LargeBinary, nullable=False)
def __init__(self, email: str, first_name: str, last_name: str, password: str = None):
self.email = email
self.first_name = first_name
self.last_name = last_name
self.password = password
@property
def password(self):
return cipher_suite.decrypt(self._password).decode()
@password.setter
def password(self, password: str):
self._password = cipher_suite.encrypt(password.encode())
def check_password(self, password: str):
return self.password == password
@classmethod
async def find(cls, database_session: AsyncSession, where_conditions: list[Any]):
_stmt = select(cls).where(*where_conditions)
_result = await database_session.execute(_stmt)
return _result.scalars().first()

14
app/redis.py Normal file
View File

@ -0,0 +1,14 @@
import redis.asyncio as redis
from app import config
global_settings = config.get_settings()
async def get_redis():
return await redis.from_url(
global_settings.redis_url.unicode_string(),
encoding="utf-8",
decode_responses=True,
)

View File

@ -1,9 +1,12 @@
from uuid import UUID
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, ConfigDict
config = ConfigDict(from_attributes=True)
class NonsenseSchema(BaseModel):
model_config = config
name: str = Field(
title="",
description="",
@ -13,17 +16,18 @@ class NonsenseSchema(BaseModel):
description="",
)
class Config:
from_attributes = True
json_schema_extra = {
"example": {
"name": "Name for Some Nonsense",
"description": "Some Nonsense Description",
}
}
# class Config:
# from_attributes = True
# json_schema_extra = {
# "example": {
# "name": "Name for Some Nonsense",
# "description": "Some Nonsense Description",
# }
# }
class NonsenseResponse(BaseModel):
model_config = config
id: UUID = Field(
title="Id",
description="",
@ -37,12 +41,12 @@ class NonsenseResponse(BaseModel):
description="",
)
class Config:
from_attributes = True
json_schema_extra = {
"example": {
"config_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Name for Some Nonsense",
"description": "Some Nonsense Description",
}
}
# class Config:
# from_attributes = True
# json_schema_extra = {
# "example": {
# "config_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
# "name": "Name for Some Nonsense",
# "description": "Some Nonsense Description",
# }
# }

View File

@ -1,6 +1,8 @@
from uuid import UUID
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, ConfigDict
config = ConfigDict(from_attributes=True)
class StuffSchema(BaseModel):
@ -13,17 +15,18 @@ class StuffSchema(BaseModel):
description="",
)
class Config:
from_attributes = True
json_schema_extra = {
"example": {
"name": "Name for Some Stuff",
"description": "Some Stuff Description",
}
}
# class Config:
# from_attributes = True
# json_schema_extra = {
# "example": {
# "name": "Name for Some Stuff",
# "description": "Some Stuff Description",
# }
# }
class StuffResponse(BaseModel):
model_config = config
id: UUID = Field(
title="Id",
description="",
@ -37,12 +40,12 @@ class StuffResponse(BaseModel):
description="",
)
class Config:
from_attributes = True
json_schema_extra = {
"example": {
"config_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Name for Some Stuff",
"description": "Some Stuff Description",
}
}
# class Config:
# from_attributes = True
# json_schema_extra = {
# "example": {
# "config_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
# "name": "Name for Some Stuff",
# "description": "Some Stuff Description",
# }
# }

33
app/schemas/user.py Normal file
View File

@ -0,0 +1,33 @@
from uuid import UUID
from pydantic import BaseModel, Field, EmailStr, ConfigDict
config = ConfigDict(from_attributes=True)
# TODO: add pydantic field validator for strong password
class UserSchema(BaseModel):
model_config = config
email: EmailStr = Field(title="Users email", description="Users email")
first_name: str = Field(title="Users first name", description="Users first name")
last_name: str = Field(title="Users last name", description="Users last name")
password: str = Field(title="Users password", description="Users password")
class UserResponse(BaseModel):
uuid: UUID = Field(title="Users id", description="Users id")
email: EmailStr = Field(title="Users email", description="Users email")
first_name: str = Field(title="Users first name", description="Users first name")
last_name: str = Field(title="Users last name", description="Users last name")
access_token: str = Field(title="Users token", description="Users token")
class TokenResponse(BaseModel):
access_token: str = Field(title="Users access token", description="Users access token")
token_type: str = Field(title="Users token type", description="Users token type")
class UserLogin(BaseModel):
model_config = config
email: EmailStr = Field(title="Users email", description="Users email")
password: str = Field(title="Users password", description="Users password")

0
app/services/__init__.py Normal file
View File

47
app/services/auth.py Normal file
View File

@ -0,0 +1,47 @@
import time
import jwt
from app import config
from app.models.user import User
from fastapi import Request, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
global_settings = config.get_settings()
async def verify_jwt(request: Request, token: str) -> bool:
_payload = await request.app.state.redis.get(token)
if _payload:
return True
else:
return False
class AuthBearer(HTTPBearer):
def __init__(self, auto_error: bool = True):
super().__init__(auto_error=auto_error)
async def __call__(self, request: Request):
credentials: HTTPAuthorizationCredentials = await super(AuthBearer, self).__call__(request)
if credentials:
if not credentials.scheme == "Bearer":
raise HTTPException(status_code=403, detail="Invalid authentication scheme.")
if not await verify_jwt(request, credentials.credentials):
raise HTTPException(status_code=403, detail="Invalid token or expired token.")
return credentials.credentials
else:
raise HTTPException(status_code=403, detail="Invalid authorization code.")
async def create_access_token(user: User, request: Request):
_payload = {
"email": user.email,
"expiry": time.time() + global_settings.jwt_expire,
"platform": request.headers.get("User-Agent"),
}
_token = jwt.encode(_payload, str(user.password), algorithm=global_settings.jwt_algorithm)
_bool = await request.app.state.redis.set(_token, str(_payload), ex=global_settings.jwt_expire)
if _bool:
return _token

View File

@ -2,7 +2,7 @@ version: '3.9'
services:
app:
image: fastapi-sqlalchemy-asyncpg_app
container_name: fsap_app
build: .
env_file:
- .env
@ -22,9 +22,10 @@ services:
- "8080:8080"
depends_on:
- db
- redis
db:
image: fastapi-sqlalchemy-asyncpg_db
container_name: fsap_db
build:
context: ./db
dockerfile: Dockerfile
@ -46,5 +47,14 @@ services:
timeout: 5s
retries: 5
redis:
image: redis:latest
container_name: fsap_redis
ports:
- "6379:6379"
env_file:
- .env
entrypoint: redis-server --appendonly yes
volumes:
fastapi_postgres_data: {}

891
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "fastapi-sqlalchemy-asyncpg"
version = "0.0.5"
version = "0.0.6"
description = ""
authors = ["Jakub Miazek <the@grillazz.com>"]
packages = []
@ -11,7 +11,7 @@ python = "^3.11"
alembic = "*"
asyncpg = "*"
httpx = "*"
pydantic = "*"
pydantic = {version = "*", extras = ["email"]}
sqlalchemy = "*"
fastapi = "*"
uvicorn = { extras = ["standard"], version = "*" }
@ -26,7 +26,10 @@ ipython = "*"
pytest-cov = "*"
pytest-asyncio = "*"
ruff = "*"
pydantic-settings = "^2.0.1"
pydantic-settings = "*"
cryptography = "*"
pyjwt = {version = "*", extras = ["cryptography"]}
redis = "*"
[tool.poetry.group.dev.dependencies]
tryceratops = "^1.1.0"

32
tests/api/test_auth.py Normal file
View File

@ -0,0 +1,32 @@
import pytest
from httpx import AsyncClient
from starlette import status
import jwt
pytestmark = pytest.mark.anyio
# TODO: parametrize test with diff urls
async def test_add_user(client: AsyncClient):
payload = {"email": "rancher@grassroots.com", "first_name": "Joe", "last_name": "Garcia", "password": "s1lly"}
response = await client.post("/user/", json=payload)
assert response.status_code == status.HTTP_201_CREATED
claimset = jwt.decode(response.json()["access_token"], options={"verify_signature": False})
assert claimset["email"] == payload["email"]
assert claimset["expiry"] > 0
assert claimset["platform"] == "python-httpx/0.24.1"
# TODO: parametrize test with diff urls including 404 and 401
async def test_get_token(client: AsyncClient):
payload = {"email": "rancher@grassroots.com", "password": "s1lly"}
response = await client.post("/user/token", json=payload)
assert response.status_code == status.HTTP_201_CREATED
claimset = jwt.decode(response.json()["access_token"], options={"verify_signature": False})
assert claimset["email"] == payload["email"]
assert claimset["expiry"] > 0
assert claimset["platform"] == "python-httpx/0.24.1"
# TODO: baerer token test
# TODO: > get token > test endpoint auth with token > expire token on redis > test endpoint auth with token

12
tests/api/test_health.py Normal file
View File

@ -0,0 +1,12 @@
import pytest
from fastapi import status
from httpx import AsyncClient
pytestmark = pytest.mark.anyio
async def test_redis_health(client: AsyncClient):
response = await client.get("/public/health/redis")
assert response.status_code == status.HTTP_200_OK
# assert payload["name"] == response.json()["name"]
# assert UUID(response.json()["id"])

View File

@ -26,7 +26,7 @@ async def test_add_stuff(client: AsyncClient, payload: dict, status_code: int):
"payload, status_code",
(
(
{"name": "motorhead", "description": "we play rock and roll"},
{"name": "motorhead-0", "description": "we play rock and roll"},
status.HTTP_200_OK,
),
),
@ -44,13 +44,14 @@ async def test_get_stuff(client: AsyncClient, payload: dict, status_code: int):
"payload, status_code",
(
(
{"name": "motorhead", "description": "we play rock and roll"},
{"name": "motorhead-1", "description": "we play rock and roll"},
status.HTTP_200_OK,
),
),
)
async def test_delete_stuff(client: AsyncClient, payload: dict, status_code: int):
response = await client.post("/stuff", json=payload)
print(response.json())
name = response.json()["name"]
response = await client.delete(f"/stuff/{name}")
assert response.status_code == status_code

View File

@ -1,21 +1,23 @@
import pytest
import pytest_asyncio
from httpx import AsyncClient
from app.database import engine
from app.main import app
from app.models.base import Base
from app.redis import get_redis
@pytest.fixture(
scope="session",
params=[
pytest.param(("asyncio", {"use_uvloop": True}), id="asyncio+uvloop"),
]
],
)
def anyio_backend(request):
return request.param
@pytest.fixture(scope="session")
async def start_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@ -25,15 +27,12 @@ async def start_db():
await engine.dispose()
@pytest_asyncio.fixture
async def client() -> AsyncClient:
@pytest.fixture(scope="session")
async def client(start_db) -> AsyncClient:
async with AsyncClient(
app=app,
base_url="http://testserver/v1",
headers={"Content-Type": "application/json"},
) as client:
await start_db()
yield client
# for AsyncEngine created in function scope, close and
# clean-up pooled connections
await engine.dispose()
) as test_client:
app.state.redis = await get_redis()
yield test_client