Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Develop #39

Merged
merged 3 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 0 additions & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,3 @@ include =
omit =
*/tests/*
*/src/common/*
*/src/config/*
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ FRONTEND_PATH_ACTIVATE_ACCOUNT=<ChangeMe>
FRONTEND_PATH_LOGIN=<ChangeMe>
REGISTER_WITH_EMAIL=<ChangeMe>

LIST_ROLES_ENDPOINT_SECURITY_ENABLED=<ChangeMe>
REGISTER_USER_ENDPOINT_SECURITY_ENABLED=<ChangeMe>

# CONFIG DEFAULT ADMIN USER
DEFAULT_ADMIN_FULLNAME=<ChangeMe>
DEFAULT_ADMIN_EMAIL=<ChangeMe>
Expand Down Expand Up @@ -70,3 +73,9 @@ SMS_CLIENT_ID=<ChangeMe>
SMS_SENDER=<ChangeMe>
SMS_API_KEY=<ChangeMe>
SMS_URL=<ChangeMe>

# REDIS CONFIG
REDIS_PASSWORD=unsta
REDIS_LOG_LEVEL=warning
REDIS_EXPIRE_CACHE=300
CACHE_DB_URL=redis://redis:6379/0
2 changes: 1 addition & 1 deletion .isort.cfg
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
[settings]
known_third_party = beanie,email_validator,fastapi,fastapi_jwt,fastapi_pagination,httpx,jinja2,jose,mongomock_motor,pwdlib,pydantic,pydantic_settings,pymongo,pyotp,pytest,pytest_asyncio,slugify,starlette,typer,uvicorn
known_third_party = beanie,email_validator,fastapi,fastapi_cache,fastapi_jwt,fastapi_pagination,httpx,jinja2,jose,mongomock_motor,pwdlib,pydantic,pydantic_settings,pymongo,pyotp,pytest,pytest_asyncio,redis,slugify,starlette,typer,uvicorn
13 changes: 12 additions & 1 deletion docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ services:
logging: *logging

mongo:
image: mongo:jammy
image: mongo:7.0.12
restart: always
environment:
MONGO_DB: "${MONGO_DB}"
Expand All @@ -39,5 +39,16 @@ services:
- auth_data:/data/db
logging: *logging

redis:
image: redis:7.4-alpine
restart: always
command: redis-server --loglevel ${REDIS_LOG_LEVEL:-"warning"}
volumes:
- redis_data:/data
env_file:
- ./dotenv/redis.env
logging: *logging

volumes:
auth_data:
redis_data:
10 changes: 6 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@ packages = [{include = "src" }]
python = "^3.12"
beanie = "^1.26.0"
python-slugify = "^8.0.4"
fastapi-pagination = "^0.12.26"
pwdlib = {extras = ["argon2", "bcrypt"], version = "^0.2.0"}
pydantic-settings = "^2.4.0"
fastapi-jwt = "^0.3.0"
python-jose = "^3.3.0"
pyotp = "^2.9.0"
httptools = "^0.6.1"
fastapi = {extras = ["standard"], version = "^0.112.1"}
uvloop = "^0.20.0"
fastapi-cache2 = {extras = ["redis"], version = "^0.2.2"}
fastapi = {extras = ["standard"], version = "^0.115.0"}
fastapi-pagination = "^0.12.27"
pydantic-settings = "^2.5.2"
pwdlib = {extras = ["argon2", "bcrypt"], version = "^0.2.1"}


[tool.poetry.group.test.dependencies]
Expand All @@ -30,6 +31,7 @@ faker = "^26.1.0"
setuptools = "^72.1.0"
pytest-cov = "^5.0.0"
pytest-dotenv = "^0.5.2"
fakeredis = "^2.24.1"


[tool.poetry.group.dev.dependencies]
Expand Down
6 changes: 6 additions & 0 deletions src/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
from fastapi import FastAPI, HTTPException
from fastapi.encoders import jsonable_encoder
from fastapi.responses import RedirectResponse
from fastapi_cache import FastAPICache
from fastapi_cache.backends.redis import RedisBackend
from fastapi_pagination import add_pagination
from httpx import AsyncClient
from redis import asyncio as aioredis
from slugify import slugify
from starlette import status
from starlette.requests import Request
Expand Down Expand Up @@ -41,6 +44,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[State]:

blacklist_token.init_blacklist_token_file()

redis = aioredis.from_url(settings.CACHE_DB_URL)
FastAPICache.init(RedisBackend(redis), prefix=f"__{settings.APP_NAME.lower()}")

yield
await shutdown_db(app=app)

Expand Down
7 changes: 7 additions & 0 deletions src/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ class AuthBaseConfig(BaseSettings):
LIST_ROLES_ENDPOINT_SECURITY_ENABLED: Optional[bool] = Field(
default=False, alias="LIST_ROLES_ENDPOINT_SECURITY_ENABLED"
)
REGISTER_USER_ENDPOINT_SECURITY_ENABLED: Optional[bool] = Field(
default=False, alias="REGISTER_USER_ENDPOINT_SECURITY_ENABLED"
)

# USER MODEL NAME
USER_MODEL_NAME: str = Field(..., alias="USER_MODEL_NAME")
Expand All @@ -35,6 +38,10 @@ class AuthBaseConfig(BaseSettings):
MONGO_DB: str = Field(..., alias="MONGO_DB")
MONGODB_URI: str = Field(..., alias="MONGODB_URI")

# REDIS CONFIG
CACHE_DB_URL: str = Field(default="redis://redis:6379/0", alias="CACHE_DB_URL")
EXPIRE_CACHE: Optional[PositiveInt] = Field(default=500, alias="EXPIRE_CACHE")


@lru_cache
def get_settings() -> AuthBaseConfig:
Expand Down
27 changes: 26 additions & 1 deletion src/middleware/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from fastapi import Request, status
from fastapi.security import HTTPBearer
from fastapi_cache.decorator import cache
from fastapi_jwt import JwtAccessBearer
from jose import ExpiredSignatureError, jwt, JWTError
from pwdlib import PasswordHash
Expand All @@ -13,10 +14,11 @@
from slugify import slugify

from src.common.helpers.exceptions import CustomHTTException
from src.config import jwt_settings
from src.config import jwt_settings, settings
from src.services.roles import get_one_role
from src.shared import blacklist_token
from src.shared.error_codes import AuthErrorCode
from src.shared.utils import custom_key_builder

logging.basicConfig(format="%(message)s", level=logging.INFO)

Expand Down Expand Up @@ -80,7 +82,18 @@ def decode_access_token(cls, token: str) -> dict:
return result

@classmethod
@cache(expire=settings.EXPIRE_CACHE, key_builder=custom_key_builder) # noqa
async def verify_access_token(cls, token: str) -> bool:
"""
Verifies the validity of an access token by checking the cache and token properties.

:param token: The access token to verify.
:type token: str
:return: True if the token is valid, otherwise raises a CustomHTTException.
:rtype: bool
:raises CustomHTTException: If the token is expired or invalid, raises a CustomHTTException.
"""

try:
if await blacklist_token.is_token_blacklisted(token):
raise CustomHTTException(
Expand All @@ -107,6 +120,18 @@ async def verify_access_token(cls, token: str) -> bool:

@classmethod
async def check_permissions(cls, token: str, required_permissions: Set[str] = ()) -> bool:
"""
Checks if the token has the required permissions.

:param token: The access token.
:type token: str
:param required_permissions: A set of required permissions.
:type required_permissions: Set[str]
:return: True if the user has the required permissions, otherwise raises a CustomHTTException.
:rtype: bool
:raises CustomHTTException: If the user doesn't have the required permissions.
"""

docode_token = cls.decode_access_token(token)
user_role_id = docode_token["subject"]["role"]

Expand Down
4 changes: 4 additions & 0 deletions src/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from beanie import PydanticObjectId
from fastapi import APIRouter, BackgroundTasks, Body, Depends, Query, Request, status
from fastapi_cache.decorator import cache

from src.config import enable_endpoint, settings
from src.middleware import AuthorizedHTTPBearer
Expand All @@ -16,6 +17,7 @@
VerifyOTP,
)
from src.services import auth
from src.shared.utils import custom_key_builder

auth_router = APIRouter(prefix="", tags=["AUTH"], redirect_slashes=False)

Expand Down Expand Up @@ -75,6 +77,7 @@ async def logout(request: Request):
summary="Check user access",
status_code=status.HTTP_200_OK,
)
@cache(expire=settings.EXPIRE_CACHE, key_builder=custom_key_builder) # noqa
async def check_access(
token: str = Depends(AuthorizedHTTPBearer),
permission: Set[str] = Query(..., title="Permission to check"),
Expand All @@ -87,6 +90,7 @@ async def check_access(
summary="Check validate access token",
status_code=status.HTTP_200_OK,
)
@cache(expire=settings.EXPIRE_CACHE, key_builder=custom_key_builder) # noqa
async def check_validate_access_token(token: str):
return await auth.validate_access_token(token=token)

Expand Down
13 changes: 9 additions & 4 deletions src/routers/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from fastapi_pagination import paginate
from pymongo import ASCENDING, DESCENDING

from src.config import settings
from src.middleware import AuthorizedHTTPBearer, CheckPermissionsHandler
from src.models import User, UserOut
from src.schemas import CreateUser, UpdateUser
Expand All @@ -16,10 +17,14 @@

@user_router.post(
"",
dependencies=[
Depends(AuthorizedHTTPBearer),
Depends(CheckPermissionsHandler(required_permissions={"auth:can-create-user"})),
],
dependencies=(
[
Depends(AuthorizedHTTPBearer),
Depends(CheckPermissionsHandler(required_permissions={"auth:can-create-user"})),
]
if settings.REGISTER_USER_ENDPOINT_SECURITY_ENABLED
else []
),
response_model=User,
response_model_exclude={"password", "is_primary"},
status_code=status.HTTP_201_CREATED,
Expand Down
39 changes: 22 additions & 17 deletions src/schemas/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,43 +12,48 @@
from .users import SignupBaseModel, PhonenumberModel


class CheckEmailOrPhone:

@model_validator(mode="before")
@classmethod
def check_email_or_phone(cls, values):
if settings.REGISTER_WITH_EMAIL:
if not values.get("email"):
raise ValueError("The email address is required")
values.pop("phonenumber", None)
else:
if not values.get("phonenumber"):
raise ValueError("Phone number is required")
values.pop("email", None)
return values


class EmailModelMixin(BaseModel):
email: Optional[EmailStr] = None


class RequestChangePassword(SignupBaseModel, EmailModelMixin):
class RequestChangePassword(SignupBaseModel, EmailModelMixin, CheckEmailOrPhone):

model_config = ConfigDict(
json_schema_extra={
"examples": [
(
{"email": "haf@example.com"}
{"email": "haf@example.com", "role": "5eb7cf5a86d9755df3a6c593"}
if settings.REGISTER_WITH_EMAIL
else {"phonenumber": "+2250151571396", "password": "password"}
else {"password": "password", "phonenumber": "+2250151571396", "role": "5eb7cf5a86d9755df3a6c593"}
)
]
}
)

@model_validator(mode="before")
@classmethod
def check_email_or_phone(cls, values):
if settings.REGISTER_WITH_EMAIL:
if not values.get("email"):
raise ValueError("The email address is required")
values.pop("phonenumber", None)
else:
if not values.get("phonenumber"):
raise ValueError("Phone number is required")
values.pop("email", None)
return values


class VerifyOTP(PhonenumberModel):
otp_code: str


class LoginUser(RequestChangePassword):
class LoginUser(BaseModel, CheckEmailOrPhone):
email: Optional[str] = None
phonenumber: Optional[str] = None
password: str

model_config = ConfigDict(
Expand Down
2 changes: 1 addition & 1 deletion src/schemas/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ def phonenumber_validation(cls, value): # noqa: B902


class SignupBaseModel(PhonenumberModel):
role: PydanticObjectId
password: Optional[str] = None


class UserBaseSchema(SignupBaseModel):
fullname: Optional[StrictStr] = Field(default=None, examples=["John Doe"])
role: Optional[PydanticObjectId] = Field(default=None, description="User role")
attributes: Optional[Dict[str, Any]] = Field(default_factory=dict, examples=[{"key": "value"}])


Expand Down
Loading
Loading