mirror of
https://github.com/grillazz/fastapi-sqlalchemy-asyncpg.git
synced 2025-08-26 16:40:40 +03:00
Merge pull request #100 from grillazz/99-jwt-auth-sample
99 jwt auth sample
This commit is contained in:
commit
9622a472d3
12
.env
12
.env
@ -8,5 +8,13 @@ SQL_USER=user
|
|||||||
SQL_PASS=secret
|
SQL_PASS=secret
|
||||||
SQL_URL=postgresql+asyncpg://${SQL_USER}:${SQL_PASS}@${SQL_HOST}/${SQL_DB}
|
SQL_URL=postgresql+asyncpg://${SQL_USER}:${SQL_PASS}@${SQL_HOST}/${SQL_DB}
|
||||||
|
|
||||||
ALGORITHM=HS256
|
REDIS_HOST=redis
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=2
|
||||||
|
REDIS_URL="redis://${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB}"
|
||||||
|
|
||||||
|
JWT_EXPIRE=3600
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
11
.github/workflows/build-and-test.yml
vendored
11
.github/workflows/build-and-test.yml
vendored
@ -24,8 +24,19 @@ jobs:
|
|||||||
POSTGRES_PASSWORD: secret
|
POSTGRES_PASSWORD: secret
|
||||||
PGPASSWORD: secret
|
PGPASSWORD: secret
|
||||||
SQL_URL: postgresql+asyncpg://app-user:secret@localhost:5432/testdb
|
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:
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:latest
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
sqldb:
|
sqldb:
|
||||||
image: postgres:14
|
image: postgres:14
|
||||||
env:
|
env:
|
||||||
|
2
.secrets
2
.secrets
@ -1,2 +1,2 @@
|
|||||||
POSTGRES_PASSWORD=secret
|
POSTGRES_PASSWORD=secret
|
||||||
SECRET_KEY=key
|
FERNET_KEY=Ms1HSn513x0_4WWFBQ3hYPDGAHpKH_pIseC5WwqyO7M=
|
35
README.md
35
README.md
@ -71,17 +71,18 @@ Below steps were done to integrate [rich](https://github.com/Textualize/rich) in
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
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
|
### Local development with poetry
|
||||||
|
|
||||||
@ -92,3 +93,17 @@ pyenv install 3.11 && pyenv local 3.11
|
|||||||
poetry install
|
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:
|
||||||
|
|
||||||
|
@ -31,7 +31,7 @@ async def run_migrations_online():
|
|||||||
and associate a connection with the context.
|
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:
|
async with connectable.connect() as connection:
|
||||||
await connection.run_sync(do_run_migrations)
|
await connection.run_sync(do_run_migrations)
|
||||||
|
40
alembic/versions/20230722_1219_2dcc708f88f8_user_auth.py
Normal file
40
alembic/versions/20230722_1219_2dcc708f88f8_user_auth.py
Normal 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
16
app/api/health.py
Normal 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
34
app/api/user.py
Normal 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"}
|
@ -1,12 +1,16 @@
|
|||||||
import os
|
import os
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from pydantic import PostgresDsn
|
from pydantic import PostgresDsn, RedisDsn
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
asyncpg_url: PostgresDsn = os.getenv("SQL_URL")
|
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
|
@lru_cache
|
||||||
|
31
app/main.py
31
app/main.py
@ -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.nonsense import router as nonsense_router
|
||||||
from app.api.shakespeare import router as shakespeare_router
|
from app.api.shakespeare import router as shakespeare_router
|
||||||
from app.api.stuff import router as stuff_router
|
from app.api.stuff import router as stuff_router
|
||||||
from app.utils.logging import AppLogger
|
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()
|
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(stuff_router)
|
||||||
app.include_router(nonsense_router)
|
app.include_router(nonsense_router)
|
||||||
app.include_router(shakespeare_router)
|
app.include_router(shakespeare_router)
|
||||||
|
app.include_router(user_router)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
app.include_router(health_router, prefix="/v1/public/health", tags=["Health, Public"])
|
||||||
async def startup_event():
|
app.include_router(health_router, prefix="/v1/health", tags=["Health, Bearer"], dependencies=[Depends(AuthBearer())])
|
||||||
logger.info("Starting up...")
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
|
||||||
async def shutdown_event():
|
|
||||||
logger.info("Shutting down...")
|
|
||||||
|
@ -2,3 +2,4 @@
|
|||||||
from app.models.nonsense import * # noqa
|
from app.models.nonsense import * # noqa
|
||||||
from app.models.shakespeare import * # noqa
|
from app.models.shakespeare import * # noqa
|
||||||
from app.models.stuff import * # noqa
|
from app.models.stuff import * # noqa
|
||||||
|
from app.models.user import * # noqa
|
||||||
|
50
app/models/user.py
Normal file
50
app/models/user.py
Normal 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
14
app/redis.py
Normal 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,
|
||||||
|
)
|
@ -1,9 +1,12 @@
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
|
|
||||||
|
config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
class NonsenseSchema(BaseModel):
|
class NonsenseSchema(BaseModel):
|
||||||
|
model_config = config
|
||||||
name: str = Field(
|
name: str = Field(
|
||||||
title="",
|
title="",
|
||||||
description="",
|
description="",
|
||||||
@ -13,17 +16,18 @@ class NonsenseSchema(BaseModel):
|
|||||||
description="",
|
description="",
|
||||||
)
|
)
|
||||||
|
|
||||||
class Config:
|
# class Config:
|
||||||
from_attributes = True
|
# from_attributes = True
|
||||||
json_schema_extra = {
|
# json_schema_extra = {
|
||||||
"example": {
|
# "example": {
|
||||||
"name": "Name for Some Nonsense",
|
# "name": "Name for Some Nonsense",
|
||||||
"description": "Some Nonsense Description",
|
# "description": "Some Nonsense Description",
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
|
|
||||||
|
|
||||||
class NonsenseResponse(BaseModel):
|
class NonsenseResponse(BaseModel):
|
||||||
|
model_config = config
|
||||||
id: UUID = Field(
|
id: UUID = Field(
|
||||||
title="Id",
|
title="Id",
|
||||||
description="",
|
description="",
|
||||||
@ -37,12 +41,12 @@ class NonsenseResponse(BaseModel):
|
|||||||
description="",
|
description="",
|
||||||
)
|
)
|
||||||
|
|
||||||
class Config:
|
# class Config:
|
||||||
from_attributes = True
|
# from_attributes = True
|
||||||
json_schema_extra = {
|
# json_schema_extra = {
|
||||||
"example": {
|
# "example": {
|
||||||
"config_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
# "config_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||||
"name": "Name for Some Nonsense",
|
# "name": "Name for Some Nonsense",
|
||||||
"description": "Some Nonsense Description",
|
# "description": "Some Nonsense Description",
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
|
|
||||||
|
config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
class StuffSchema(BaseModel):
|
class StuffSchema(BaseModel):
|
||||||
@ -13,17 +15,18 @@ class StuffSchema(BaseModel):
|
|||||||
description="",
|
description="",
|
||||||
)
|
)
|
||||||
|
|
||||||
class Config:
|
# class Config:
|
||||||
from_attributes = True
|
# from_attributes = True
|
||||||
json_schema_extra = {
|
# json_schema_extra = {
|
||||||
"example": {
|
# "example": {
|
||||||
"name": "Name for Some Stuff",
|
# "name": "Name for Some Stuff",
|
||||||
"description": "Some Stuff Description",
|
# "description": "Some Stuff Description",
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
|
|
||||||
|
|
||||||
class StuffResponse(BaseModel):
|
class StuffResponse(BaseModel):
|
||||||
|
model_config = config
|
||||||
id: UUID = Field(
|
id: UUID = Field(
|
||||||
title="Id",
|
title="Id",
|
||||||
description="",
|
description="",
|
||||||
@ -37,12 +40,12 @@ class StuffResponse(BaseModel):
|
|||||||
description="",
|
description="",
|
||||||
)
|
)
|
||||||
|
|
||||||
class Config:
|
# class Config:
|
||||||
from_attributes = True
|
# from_attributes = True
|
||||||
json_schema_extra = {
|
# json_schema_extra = {
|
||||||
"example": {
|
# "example": {
|
||||||
"config_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
# "config_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||||
"name": "Name for Some Stuff",
|
# "name": "Name for Some Stuff",
|
||||||
"description": "Some Stuff Description",
|
# "description": "Some Stuff Description",
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
|
33
app/schemas/user.py
Normal file
33
app/schemas/user.py
Normal 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="User’s email", description="User’s email")
|
||||||
|
first_name: str = Field(title="User’s first name", description="User’s first name")
|
||||||
|
last_name: str = Field(title="User’s last name", description="User’s last name")
|
||||||
|
password: str = Field(title="User’s password", description="User’s password")
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
uuid: UUID = Field(title="User’s id", description="User’s id")
|
||||||
|
email: EmailStr = Field(title="User’s email", description="User’s email")
|
||||||
|
first_name: str = Field(title="User’s first name", description="User’s first name")
|
||||||
|
last_name: str = Field(title="User’s last name", description="User’s last name")
|
||||||
|
access_token: str = Field(title="User’s token", description="User’s token")
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str = Field(title="User’s access token", description="User’s access token")
|
||||||
|
token_type: str = Field(title="User’s token type", description="User’s token type")
|
||||||
|
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
model_config = config
|
||||||
|
email: EmailStr = Field(title="User’s email", description="User’s email")
|
||||||
|
password: str = Field(title="User’s password", description="User’s password")
|
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
47
app/services/auth.py
Normal file
47
app/services/auth.py
Normal 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
|
@ -2,7 +2,7 @@ version: '3.9'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: fastapi-sqlalchemy-asyncpg_app
|
container_name: fsap_app
|
||||||
build: .
|
build: .
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
@ -22,9 +22,10 @@ services:
|
|||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
- redis
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: fastapi-sqlalchemy-asyncpg_db
|
container_name: fsap_db
|
||||||
build:
|
build:
|
||||||
context: ./db
|
context: ./db
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@ -46,5 +47,14 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:latest
|
||||||
|
container_name: fsap_redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
entrypoint: redis-server --appendonly yes
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
fastapi_postgres_data: {}
|
fastapi_postgres_data: {}
|
891
poetry.lock
generated
891
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "fastapi-sqlalchemy-asyncpg"
|
name = "fastapi-sqlalchemy-asyncpg"
|
||||||
version = "0.0.5"
|
version = "0.0.6"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Jakub Miazek <the@grillazz.com>"]
|
authors = ["Jakub Miazek <the@grillazz.com>"]
|
||||||
packages = []
|
packages = []
|
||||||
@ -11,7 +11,7 @@ python = "^3.11"
|
|||||||
alembic = "*"
|
alembic = "*"
|
||||||
asyncpg = "*"
|
asyncpg = "*"
|
||||||
httpx = "*"
|
httpx = "*"
|
||||||
pydantic = "*"
|
pydantic = {version = "*", extras = ["email"]}
|
||||||
sqlalchemy = "*"
|
sqlalchemy = "*"
|
||||||
fastapi = "*"
|
fastapi = "*"
|
||||||
uvicorn = { extras = ["standard"], version = "*" }
|
uvicorn = { extras = ["standard"], version = "*" }
|
||||||
@ -26,7 +26,10 @@ ipython = "*"
|
|||||||
pytest-cov = "*"
|
pytest-cov = "*"
|
||||||
pytest-asyncio = "*"
|
pytest-asyncio = "*"
|
||||||
ruff = "*"
|
ruff = "*"
|
||||||
pydantic-settings = "^2.0.1"
|
pydantic-settings = "*"
|
||||||
|
cryptography = "*"
|
||||||
|
pyjwt = {version = "*", extras = ["cryptography"]}
|
||||||
|
redis = "*"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
tryceratops = "^1.1.0"
|
tryceratops = "^1.1.0"
|
||||||
|
32
tests/api/test_auth.py
Normal file
32
tests/api/test_auth.py
Normal 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
12
tests/api/test_health.py
Normal 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"])
|
@ -26,7 +26,7 @@ async def test_add_stuff(client: AsyncClient, payload: dict, status_code: int):
|
|||||||
"payload, status_code",
|
"payload, status_code",
|
||||||
(
|
(
|
||||||
(
|
(
|
||||||
{"name": "motorhead", "description": "we play rock and roll"},
|
{"name": "motorhead-0", "description": "we play rock and roll"},
|
||||||
status.HTTP_200_OK,
|
status.HTTP_200_OK,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -44,13 +44,14 @@ async def test_get_stuff(client: AsyncClient, payload: dict, status_code: int):
|
|||||||
"payload, status_code",
|
"payload, status_code",
|
||||||
(
|
(
|
||||||
(
|
(
|
||||||
{"name": "motorhead", "description": "we play rock and roll"},
|
{"name": "motorhead-1", "description": "we play rock and roll"},
|
||||||
status.HTTP_200_OK,
|
status.HTTP_200_OK,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
async def test_delete_stuff(client: AsyncClient, payload: dict, status_code: int):
|
async def test_delete_stuff(client: AsyncClient, payload: dict, status_code: int):
|
||||||
response = await client.post("/stuff", json=payload)
|
response = await client.post("/stuff", json=payload)
|
||||||
|
print(response.json())
|
||||||
name = response.json()["name"]
|
name = response.json()["name"]
|
||||||
response = await client.delete(f"/stuff/{name}")
|
response = await client.delete(f"/stuff/{name}")
|
||||||
assert response.status_code == status_code
|
assert response.status_code == status_code
|
||||||
|
@ -1,21 +1,23 @@
|
|||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from app.database import engine
|
from app.database import engine
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
|
from app.redis import get_redis
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(
|
@pytest.fixture(
|
||||||
|
scope="session",
|
||||||
params=[
|
params=[
|
||||||
pytest.param(("asyncio", {"use_uvloop": True}), id="asyncio+uvloop"),
|
pytest.param(("asyncio", {"use_uvloop": True}), id="asyncio+uvloop"),
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
def anyio_backend(request):
|
def anyio_backend(request):
|
||||||
return request.param
|
return request.param
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
async def start_db():
|
async def start_db():
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
@ -25,15 +27,12 @@ async def start_db():
|
|||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest.fixture(scope="session")
|
||||||
async def client() -> AsyncClient:
|
async def client(start_db) -> AsyncClient:
|
||||||
async with AsyncClient(
|
async with AsyncClient(
|
||||||
app=app,
|
app=app,
|
||||||
base_url="http://testserver/v1",
|
base_url="http://testserver/v1",
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
) as client:
|
) as test_client:
|
||||||
await start_db()
|
app.state.redis = await get_redis()
|
||||||
yield client
|
yield test_client
|
||||||
# for AsyncEngine created in function scope, close and
|
|
||||||
# clean-up pooled connections
|
|
||||||
await engine.dispose()
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user