Skip to content

Commit

Permalink
Merge pull request #100 from grillazz/99-jwt-auth-sample
Browse files Browse the repository at this point in the history
99 jwt auth sample
  • Loading branch information
grillazz authored Jul 24, 2023
2 parents d9e88d8 + f77ab40 commit 9622a47
Show file tree
Hide file tree
Showing 25 changed files with 979 additions and 416 deletions.
12 changes: 10 additions & 2 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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



11 changes: 11 additions & 0 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .secrets
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
POSTGRES_PASSWORD=secret
SECRET_KEY=key
FERNET_KEY=Ms1HSn513x0_4WWFBQ3hYPDGAHpKH_pIseC5WwqyO7M=
35 changes: 25 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:

2 changes: 1 addition & 1 deletion alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions alembic/versions/20230722_1219_2dcc708f88f8_user_auth.py
Original file line number Diff line number Diff line change
@@ -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 changes: 16 additions & 0 deletions app/api/health.py
Original file line number Diff line number Diff line change
@@ -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 changes: 34 additions & 0 deletions app/api/user.py
Original file line number Diff line number Diff line change
@@ -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"}
6 changes: 5 additions & 1 deletion app/config.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
31 changes: 21 additions & 10 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -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())])
1 change: 1 addition & 0 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 50 additions & 0 deletions app/models/user.py
Original file line number Diff line number Diff line change
@@ -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 changes: 14 additions & 0 deletions app/redis.py
Original file line number Diff line number Diff line change
@@ -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,
)
40 changes: 22 additions & 18 deletions app/schemas/nnonsense.py
Original file line number Diff line number Diff line change
@@ -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="",
Expand All @@ -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="",
Expand All @@ -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",
# }
# }
Loading

0 comments on commit 9622a47

Please sign in to comment.