refractor structure project and add user endpoint

This commit is contained in:
2026-01-08 15:05:23 +03:00
parent 423ce8bc62
commit 8e0131451d
25 changed files with 329 additions and 218 deletions
@@ -1,15 +1,14 @@
import logging
from typing import Optional
import uuid
from fastapi import Depends, HTTPException, status
from jose import jwt, JWTError
from app.utils.OAuth2WithCookie import OAuth2PasswordBearerWithCookie
from app.config import settings
from app.core.config import settings
from app.users.models import UserModel
from app.users.service import UserService
from app.exceptions import InvalidTokenException
from app.core.exceptions import InvalidTokenException
log = logging.getLogger(__name__)
oauth2_scheme = OAuth2PasswordBearerWithCookie(tokenUrl="/api/v1/auth/login")
@@ -52,5 +51,7 @@ async def get_current_superuser(current_user: UserModel = Depends(get_current_us
async def get_current_verified_user(current_user: UserModel = Depends(get_current_user)):
if not current_user.is_verified:
log.debug("User has not confirmed the email.", extra={"user_id": current_user.id})
log.debug("User has not confirmed the email.", extra={"user_id": str(current_user.id)})
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="verify email")
return current_user
+89
View File
@@ -0,0 +1,89 @@
from typing import Dict
import logging
import uuid
from fastapi import APIRouter, status, Response, Depends, Request, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from app.users.schemas import UserCreate, User
from app.auth.schemas import Token
from app.users.service import UserService
from app.auth.service import AuthService
from app.users.models import UserModel
from app.core.exceptions import InvalidCredentialsException
from app.auth.dependencies import get_current_user
from app.core.config import settings
router = APIRouter(prefix="/auth", tags=["Auth"])
log = logging.getLogger(__name__)
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(user: UserCreate) -> User:
return await UserService.register_new_user(user)
@router.get("/verify/{token}")
async def verify_email(token: uuid.UUID) -> Dict:
await UserService.verify_email(token)
return {"status": True, "message": "User successfully verified email"}
@router.post("/send/verify-email")
async def resend_verify_email(user: UserModel = Depends(get_current_user)) -> Dict:
if user.is_verified:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Email already verified")
await UserService.send_verify_email(user)
return {"status": True, "message": "Successfully send email letter"}
@router.post("/login")
async def login(response: Response, credentials: OAuth2PasswordRequestForm = Depends()) -> Token:
user = await AuthService.authenticate_user(credentials.username, credentials.password)
if not user:
log.warning("Failed login attempt", extra={"email or username": credentials.username})
raise InvalidCredentialsException
token = await AuthService.create_token(user.id)
response.set_cookie(
'access_token',
token.access_token,
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
httponly=True
)
response.set_cookie(
'refresh_token',
str(token.refresh_token),
max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 30 * 24 * 60,
httponly=True
)
return token
@router.post("/refresh")
async def refresh_token(request: Request, response: Response) -> Token:
new_token = await AuthService.refresh_token(uuid.UUID(request.cookies.get("refresh_token")))
response.set_cookie(
'access_token',
new_token.access_token,
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
httponly=True
)
response.set_cookie(
'refresh_token',
str(new_token.refresh_token),
max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 30 * 24 * 60,
httponly=True
)
log.debug("Token refreshed via endpoint")
return new_token
@router.post("/logout")
async def logout(request: Request, response: Response, user: UserModel = Depends(get_current_user)) -> Dict:
response.delete_cookie("access_token")
response.delete_cookie("refresh_token")
await AuthService.logout(uuid.UUID(request.cookies.get("refresh_token")))
return {"status": True, "message": "Logged out successfully"}
@router.post("/abort")
async def abort_all_sessions(user: UserModel = Depends(get_current_user)) -> Dict:
await AuthService.abort_all_sessions(user.id)
return {"status": True, "message": "All sessions was aborted"}
+9
View File
@@ -0,0 +1,9 @@
import uuid
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
refresh_token: uuid.UUID
token_type: str
+102
View File
@@ -0,0 +1,102 @@
import logging
import uuid
from datetime import datetime, timedelta
from typing import Optional
from jose import jwt
from sqlalchemy import or_
from app.utils.hash_password import verify_password
from app.services.redis_service import RefreshTokenStorage
from app.core.exceptions import InvalidTokenException
from app.users.models import UserModel
from app.users.dao import UserDAO
from app.core.database import async_session_maker
from app.auth.schemas import Token
from app.core.config import settings
log = logging.getLogger(__name__)
class AuthService:
@classmethod
async def create_token(cls, user_id: int) -> Token:
access_token = cls._create_access_token(user_id)
refresh_token_expires = timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
refresh_token = cls._create_refresh_token()
await RefreshTokenStorage.save_token(refresh_token, user_id, int(refresh_token_expires.total_seconds()))
log.info("Token created has user", extra={"user_id": user_id})
return Token(access_token=access_token, refresh_token=refresh_token, token_type="bearer")
@classmethod
async def logout(cls, token: uuid.UUID) -> None:
user_id = await RefreshTokenStorage.getdel_token(token)
log.info("User logged out", extra={"user_id": user_id})
@classmethod
async def refresh_token(cls, token: uuid.UUID) -> Token:
async with async_session_maker() as session:
refresh_session = await RefreshTokenStorage.getdel_token(token)
if refresh_session is None:
log.warning("Refresh token not found")
raise InvalidTokenException
user = await UserDAO.find_one_or_none(session, id=int(refresh_session))
if user is None:
log.error("User not found during token refresh", extra={"user_id": str(refresh_session.user_id)})
raise InvalidTokenException
access_token = cls._create_access_token(user.id)
refresh_token_expires = timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
refresh_token = cls._create_refresh_token()
await RefreshTokenStorage.save_token(
refresh_token,
user.id,
int(refresh_token_expires.total_seconds())
)
await session.commit()
log.info("Token refreshed for user", extra={"user_id": str(user.id)})
return Token(access_token=access_token, refresh_token=refresh_token, token_type="bearer")
@classmethod
async def authenticate_user(cls, email_or_username: str, password: str) -> Optional[UserModel]:
async with async_session_maker() as session:
db_user = await UserDAO.find_one_or_none(
session,
or_(
UserModel.email==email_or_username,
UserModel.username==email_or_username
)
)
if db_user and verify_password(password, db_user.hashed_password):
log.info("User authenticated successfully", extra={"username": db_user.username})
return db_user
log.warning("Authentication failed", extra={"email": email_or_username})
return None
@classmethod
async def abort_all_sessions(cls, user_id: int):
await RefreshTokenStorage.abort_all_tokens(user_id)
@classmethod
def _create_access_token(cls, user_id: int) -> str:
to_encode = {
"sub": str(user_id),
"exp": datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
}
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return f'Bearer {encoded_jwt}'
@classmethod
def _create_refresh_token(cls) -> uuid.UUID:
return uuid.uuid4()
View File
@@ -1,9 +1,9 @@
from celery import Celery
from app.config import settings
from app.core.config import settings
celery_app = Celery(
"app.utils.celery_app",
"app.core.celery_app",
broker=settings.RABBITMQ_URL,
backend="rpc://"
)
@@ -14,7 +14,7 @@ class Settings(BaseSettings):
WORKERS: int
URL: str
CORS_ORIGINS: List[str] = ["*"]
CORS_ORIGINS: List[str] = ["http://localhost:5500", "http://127.0.0.1:5500", "http://localhost:8080", "http://127.0.0.1:8080", "null"]
CORS_HEADERS: List[str] = ["*"]
CORS_METHODS: List[str] = ["*"]
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError
from pydantic import BaseModel
from app.database import Base
from app.core.database import Base
log = logging.getLogger(__name__)
@@ -1,12 +1,11 @@
from datetime import datetime
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
from sqlalchemy.engine import create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import MetaData, NullPool, func
from app.config import settings
from app.constants import DB_NAMING_CONVENTION
from app.core.config import settings
from app.core.constants import DB_NAMING_CONVENTION
class Base(DeclarativeBase):
@@ -1,6 +1,6 @@
from logging.config import dictConfig
from app.config import settings
from app.core.config import settings
LOGGING_CONFIG = {
"version": 1,
@@ -1,6 +1,6 @@
from redis.asyncio import Redis, from_url
from app.config import settings
from app.core.config import settings
redis_client: Redis = None
+5 -4
View File
@@ -5,10 +5,11 @@ import logging
from fastapi import FastAPI, APIRouter, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from app.utils.redis import close_redis, init_redis
from app.users.router import user_router, auth_router
from app.log_config import set_logging
from app.config import settings
from app.core.redis import close_redis, init_redis
from app.users.router import router as user_router
from app.auth.router import router as auth_router
from app.core.log_config import set_logging
from app.core.config import settings
set_logging()
log = logging.getLogger(__name__)
+2 -3
View File
@@ -7,9 +7,8 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.users.models import UserModel
from app.database import Base
from app.config import settings
from app.core.database import Base
from app.core.config import settings
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
+1 -1
View File
@@ -1,7 +1,7 @@
import logging
from app.utils.email_client import EmailClient
from app.config import settings
from app.core.config import settings
log = logging.getLogger(__name__)
+1 -1
View File
@@ -1,7 +1,7 @@
import logging
import uuid
from app.utils.redis import get_redis
from app.core.redis import get_redis
log = logging.getLogger(__name__)
+1 -1
View File
@@ -1,6 +1,6 @@
import logging
from app.utils.celery_app import celery_app
from app.core.celery_app import celery_app
from app.services.email_service import EmailService
log = logging.getLogger(__name__)
+1 -1
View File
@@ -1,4 +1,4 @@
from app.dao import BaseDAO
from app.core.dao import BaseDAO
from app.users.models import UserModel
from app.users.schemas import UserCreateDB, UserUpdateDB
+2 -11
View File
@@ -1,10 +1,9 @@
from datetime import date
import uuid
from sqlalchemy.orm import mapped_column, Mapped
from sqlalchemy import DATE, UUID, ForeignKey
from sqlalchemy import DATE
from app.database import Base
from app.core.database import Base
class UserModel(Base):
__tablename__ = "user"
@@ -20,11 +19,3 @@ class UserModel(Base):
is_verified: Mapped[bool] = mapped_column(default=False)
is_superuser: Mapped[bool] = mapped_column(default=False)
hashed_password: Mapped[str] = mapped_column()
# class RefreshSessionModel(Base):
# __tablename__ = "refresh_session"
#
# id: Mapped[int] = mapped_column(primary_key=True, index=True)
# refresh_token: Mapped[uuid.UUID] = mapped_column(UUID, index=True)
# expires_in: Mapped[int] = mapped_column()
# user_ud: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"))
+24 -73
View File
@@ -1,88 +1,39 @@
from typing import Dict
import logging
import uuid
from fastapi import APIRouter, status, Response, Depends, Request, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from fastapi import APIRouter, Response, Depends
from app.users.schemas import UserCreate, User, Token
from app.users.service import AuthService, UserService
from app.users.schemas import User, UserUpdate
from app.users.service import UserService
from app.auth.service import AuthService
from app.users.models import UserModel
from app.exceptions import InvalidCredentialsException
from app.users.dependencies import get_current_user, get_current_verified_user
from app.config import settings
from app.auth.dependencies import get_current_verified_user, get_current_superuser
user_router = APIRouter(prefix="/users", tags=["User"])
auth_router = APIRouter(prefix="/auth", tags=["Auth"])
router = APIRouter(prefix="/users", tags=["User"])
log = logging.getLogger(__name__)
@auth_router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(user: UserCreate) -> User:
return await UserService.register_new_user(user)
@auth_router.get("/verify/{token}")
async def verify_email(token: uuid.UUID) -> Dict:
await UserService.verify_email(token)
return {"status": True, "message": "User successfully verified email"}
@router.get("/me")
async def get_current_user(user: UserModel = Depends(get_current_verified_user)) -> User:
log.debug("Getting current user profile", extra={"user_id": str(user.id)})
return user
@auth_router.post("/send/verify-email")
async def resend_verify_email(user: UserModel = Depends(get_current_user)) -> Dict:
if user.is_verified:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Email already verified")
@router.get("/")
async def get_users(offset: int, limit: int, user: UserModel = Depends(get_current_superuser)):
log.info("Getting users list", extra={"offset": offset, "limit": limit})
return await UserService.get_users_list(offset=offset, limit=limit)
await UserService.send_verify_email(user)
return {"status": True, "message": "Successfully send email letter"}
@router.put("/me")
async def update_current_user(update_user: UserUpdate, user: UserModel = Depends(get_current_verified_user)) -> User:
return await UserService.update_user(user.id, update_user)
@auth_router.post("/login")
async def login(response: Response, credentials: OAuth2PasswordRequestForm = Depends()) -> Token:
user = await AuthService.authenticate_user(credentials.username, credentials.password)
if not user:
log.warning("Failed login attempt", extra={"email or username": credentials.username})
raise InvalidCredentialsException
token = await AuthService.create_token(user.id)
response.set_cookie(
'access_token',
token.access_token,
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
httponly=True
)
response.set_cookie(
'refresh_token',
str(token.refresh_token),
max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 30 * 24 * 60,
httponly=True
)
return token
@router.delete("/me")
async def delete_current_user(response: Response, user: UserModel = Depends(get_current_verified_user)) -> Dict:
log.debug("User deleting their account", extra={"user_id": user.id, "email": user.email})
response.delete_cookie('access_token')
response.delete_cookie('refresh_token')
@auth_router.post("/refresh")
async def refresh_token(request: Request, response: Response) -> Token:
new_token = await AuthService.refresh_token(uuid.UUID(request.cookies.get("refresh_token")))
response.set_cookie(
'access_token',
new_token.access_token,
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
httponly=True
)
response.set_cookie(
'refresh_token',
str(new_token.refresh_token),
max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 30 * 24 * 60,
httponly=True
)
log.debug("Token refreshed via endpoint")
return new_token
@auth_router.post("/logout")
async def logout(request: Request, response: Response, user: UserModel = Depends(get_current_user)) -> Dict:
response.delete_cookie("access_token")
response.delete_cookie("refresh_token")
await AuthService.logout(uuid.UUID(request.cookies.get("refresh_token")))
return {"status": True, "message": "Logged out successfully"}
@auth_router.post("/abort")
async def abort_all_sessions(user: UserModel = Depends(get_current_user)) -> Dict:
await AuthService.abort_all_sessions(user.id)
return {"status": True, "message": "All sessions was aborted"}
await UserService.delete_user(user.id)
return {"status": True, "message": "User successfully deleted"}
+1 -7
View File
@@ -1,6 +1,5 @@
from typing import Optional
from datetime import date
import uuid
from pydantic import BaseModel, EmailStr
@@ -50,15 +49,10 @@ class UserCreateDB(UserBase):
class UserUpdateDB(UserBase):
email: Optional[str] = None
hashed_password: str
hashed_password: Optional[str] = None
description: Optional[str] = None
birth_day: Optional[date] = None
is_active: Optional[bool] = None
is_verified: Optional[bool] = None
is_superuser: Optional[bool] = None
class Token(BaseModel):
access_token: str
refresh_token: uuid.UUID
token_type: str
+67 -97
View File
@@ -1,110 +1,25 @@
import logging
import uuid
from datetime import datetime, timedelta
from typing import Optional
from datetime import timedelta
from typing import List
from fastapi import HTTPException, status
from jose import jwt
from sqlalchemy import or_
from app.utils.hash_password import hash_password, verify_password
from app.services.redis_service import RefreshTokenStorage, EmailTokenStorage
from app.utils.redis import get_redis
from app.exceptions import InvalidTokenException, TokenExpiredException
from app.utils.hash_password import hash_password
from app.services.redis_service import EmailTokenStorage
from app.core.redis import get_redis
from app.core.exceptions import InvalidTokenException, TokenExpiredException
from app.users.models import UserModel
from app.users.dao import UserDAO
from app.database import async_session_maker
from app.users.schemas import Token, UserCreate, UserCreateDB, User
from app.core.database import async_session_maker
from app.users.schemas import UserCreate, UserCreateDB, User, UserUpdate, UserUpdateDB
from app.tasks.email_tasks import EmailTasks
from app.config import settings
from app.core.config import settings
log = logging.getLogger(__name__)
class AuthService:
@classmethod
async def create_token(cls, user_id: int) -> Token:
access_token = cls._create_access_token(user_id)
refresh_token_expires = timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
refresh_token = cls._create_refresh_token()
await RefreshTokenStorage.save_token(refresh_token, user_id, int(refresh_token_expires.total_seconds()))
log.info("Token created has user", extra={"user_id": user_id})
return Token(access_token=access_token, refresh_token=refresh_token, token_type="bearer")
@classmethod
async def logout(cls, token: uuid.UUID) -> None:
user_id = await RefreshTokenStorage.getdel_token(token)
log.info("User logged out", extra={"user_id": user_id})
@classmethod
async def refresh_token(cls, token: uuid.UUID) -> Token:
async with async_session_maker() as session:
refresh_session = await RefreshTokenStorage.getdel_token(token)
if refresh_session is None:
log.warning("Refresh token not found")
raise InvalidTokenException
user = await UserDAO.find_one_or_none(session, id=int(refresh_session))
if user is None:
log.error("User not found during token refresh", extra={"user_id": str(refresh_session.user_id)})
raise InvalidTokenException
access_token = cls._create_access_token(user.id)
refresh_token_expires = timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
refresh_token = cls._create_refresh_token()
await RefreshTokenStorage.save_token(
refresh_token,
user.id,
int(refresh_token_expires.total_seconds())
)
await session.commit()
log.info("Token refreshed for user", extra={"user_id": str(user.id)})
return Token(access_token=access_token, refresh_token=refresh_token, token_type="bearer")
@classmethod
async def authenticate_user(cls, email_or_username: str, password: str) -> Optional[UserModel]:
async with async_session_maker() as session:
db_user = await UserDAO.find_one_or_none(
session,
or_(
UserModel.email==email_or_username,
UserModel.username==email_or_username
)
)
if db_user and verify_password(password, db_user.hashed_password):
log.info("User authenticated successfully", extra={"username": db_user.username})
return db_user
log.warning("Authentication failed", extra={"email": email_or_username})
return None
@classmethod
async def abort_all_sessions(cls, user_id: int):
await RefreshTokenStorage.abort_all_tokens(user_id)
@classmethod
def _create_access_token(cls, user_id: int) -> str:
to_encode = {
"sub": str(user_id),
"exp": datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
}
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return f'Bearer {encoded_jwt}'
@classmethod
def _create_refresh_token(cls) -> uuid.UUID:
return uuid.uuid4()
class UserService:
@classmethod
async def get_user(cls, user_id: int) -> User:
@@ -120,8 +35,6 @@ class UserService:
@classmethod
async def register_new_user(cls, user: UserCreate) -> User:
redis_client = await get_redis()
async with async_session_maker() as session:
user_exist = await UserDAO.find_one_or_none(session, or_(
UserModel.email==user.email,
@@ -172,7 +85,6 @@ class UserService:
@classmethod
async def verify_email(cls, token: uuid.UUID):
redis_client = await get_redis()
async with async_session_maker() as session:
user_id = await EmailTokenStorage.getdel_token(token)
@@ -192,3 +104,61 @@ class UserService:
)
await session.commit()
@classmethod
async def get_users_list(cls, offset: int = 0, limit: int = 10) -> List[UserModel]:
async with async_session_maker() as session:
users = await UserDAO.find_all(session, offset, limit)
if users is None:
log.warning("Users not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Users not found")
log.debug("Users fetched", extra={"count": len(users), "offset": offset, "limit": limit})
return users
@classmethod
async def update_user(cls, user_id: int, update_user: UserUpdate):
async with async_session_maker() as session:
user_exist = await UserDAO.find_one_or_none(session, id=user_id)
if user_exist is None:
log.warning("User not found", extra={"user_id": user_id})
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
if user_exist.username != update_user.username:
username_exist = await UserDAO.find_one_or_none(session, username=update_user.username)
if username_exist:
log.warning("Username is taken", extra={"user_id": user_id})
raise HTTPException(status.HTTP_409_CONFLICT, detail="Username is taken")
update_user_db = await UserDAO.update(
session,
UserModel.id==user_id,
obj_in=UserUpdateDB(
**update_user.model_dump()
)
)
await session.commit()
log.info("User updated", extra={"user_id": user_id})
return update_user_db
@classmethod
async def delete_user(cls, user_id):
async with async_session_maker() as session:
user_exist = await UserDAO.find_one_or_none(session, id=user_id)
if user_exist is None:
log.warning("User not found", extra={"user_id": user_id})
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
await UserDAO.update(
session,
UserModel.id==user_id,
obj_in={"is_active": False}
)
await session.commit()
log.info("User is inactive", extra={"user_id": user_id})
+7 -2
View File
@@ -1,10 +1,13 @@
import json
import logging
from functools import wraps
from fastapi import Request
from fastapi.encoders import jsonable_encoder
from app.utils.redis import get_redis
from app.core.redis import get_redis
log = logging.getLogger(__name__)
def cache(ttl: int = 10):
if ttl <= 0:
@@ -17,10 +20,12 @@ def cache(ttl: int = 10):
request: Request = kwargs.get("request")
response_cache = await redis.get(str(request.url))
if response_cache is not None:
log.debug("Getting from cache")
return json.loads(response_cache)
response_cache = await func(*args, **kwargs)
await redis.setex(str(request.url), ttl, json.dumps(response_cache))
serializable_data = jsonable_encoder(response_cache)
await redis.setex(str(request.url), ttl, json.dumps(serializable_data))
return response_cache
return wrapper
+1 -1
View File
@@ -7,7 +7,7 @@ from email.mime.multipart import MIMEMultipart
from jinja2 import Environment, FileSystemLoader
from app.config import settings
from app.core.config import settings
log = logging.getLogger(__name__)