Create authorization system

This commit is contained in:
2026-01-05 23:31:36 +03:00
parent d438f7bf5b
commit 2e14a7f364
39 changed files with 2500 additions and 9 deletions
+7
View File
@@ -0,0 +1,7 @@
from app.dao import BaseDAO
from app.users.models import UserModel
from app.users.schemas import UserCreateDB, UserUpdateDB
class UserDAO(BaseDAO[UserModel, UserCreateDB, UserUpdateDB]):
model = UserModel
+56
View File
@@ -0,0 +1,56 @@
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.users.models import UserModel
from app.users.service import UserService
from app.exceptions import InvalidTokenException
log = logging.getLogger(__name__)
oauth2_scheme = OAuth2PasswordBearerWithCookie(tokenUrl="/api/v1/auth/login")
async def get_current_user(token: str = Depends(oauth2_scheme)) -> Optional[UserModel]:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=settings.ALGORITHM)
user_id = int(payload.get("sub"))
log.debug("Successfully get current_user id", extra={"user_id": user_id})
if user_id is None:
log.warning("User id is None")
raise InvalidTokenException
except (Exception, JWTError) as ex:
if isinstance(ex, InvalidTokenException):
raise ex
if isinstance(ex, JWTError):
log.error("JWT error")
raise ex
log.error("Unknown exception")
raise ex
current_user = await UserService.get_user(user_id)
if not current_user.is_active:
log.debug("User is not active", extra={"user_id": current_user.id})
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not active")
return current_user
async def get_current_superuser(current_user: UserModel = Depends(get_current_user)) -> Optional[UserModel]:
if not current_user.is_superuser:
log.debug("User not enough privileges", extra={"user_id": current_user.id})
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Not enough privileges")
return current_user
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})
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="verify email")
+30
View File
@@ -0,0 +1,30 @@
from datetime import date
import uuid
from sqlalchemy.orm import mapped_column, Mapped
from sqlalchemy import DATE, UUID, ForeignKey
from app.database import Base
class UserModel(Base):
__tablename__ = "user"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
display_name: Mapped[str] = mapped_column()
username: Mapped[str] = mapped_column(index=True, unique=True)
email: Mapped[str] = mapped_column(index=True, unique=True)
birth_day: Mapped[date] = mapped_column(DATE, nullable=True)
description: Mapped[str] = mapped_column(nullable=True)
avatar_url: Mapped[str] = mapped_column(nullable=True)
is_active: Mapped[bool] = mapped_column(default=True)
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"))
+80
View File
@@ -0,0 +1,80 @@
import logging
import uuid
from fastapi import APIRouter, status, Response, Depends, Request
from fastapi.security import OAuth2PasswordRequestForm
from app.users.schemas import UserCreate, User, Token
from app.users.service import AuthService, UserService
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
user_router = APIRouter(prefix="/users", tags=["User"])
auth_router = APIRouter(prefix="/auth", tags=["Auth"])
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):
await UserService.verify_email(token)
return {"status": True}
@auth_router.post("/send/verify_email")
async def resend_verify_email(user: UserModel = Depends(get_current_user)):
await UserService.send_verify_email(user)
return {"status": True, "message": "Successfully send email letter"}
@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
@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)):
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"}
+64
View File
@@ -0,0 +1,64 @@
from typing import Optional
from datetime import date
import uuid
from pydantic import BaseModel, EmailStr
class UserBase(BaseModel):
display_name: Optional[str] = None
username: Optional[str] = None
# email: Optional[str] = None
birth_day: Optional[date] = None
# description: str
# avatar_url: str
class UserCreate(UserBase):
display_name: str
username: str
email: EmailStr
password: str
class Config:
from_attributes = True
class UserUpdate(UserBase):
description: Optional[str] = None
birth_day: Optional[date] = None
class User(UserBase):
id: int
display_name: str
username: str
email: EmailStr
birth_day: Optional[date] = None
description: Optional[str] = None
avatar_url: Optional[str] = None
is_active: bool
is_verified: bool
is_superuser: bool
class UserCreateDB(UserBase):
email: Optional[str] = None
hashed_password: Optional[str] = None
is_active: Optional[bool] = None
is_verified: Optional[bool] = None
is_superuser: Optional[bool] = None
class UserUpdateDB(UserBase):
email: Optional[str] = None
hashed_password: str
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
+193
View File
@@ -0,0 +1,193 @@
import logging
import uuid
from datetime import datetime, timedelta
from typing import Optional
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.utils.redis import get_redis
from app.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.tasks.email_tasks import EmailTasks
from app.config import settings
log = logging.getLogger(__name__)
class AuthService:
@classmethod
async def create_token(cls, user_id: int) -> Token:
redis_client = await get_redis()
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 redis_client.setex(f"refresh:{refresh_token}", int(refresh_token_expires.total_seconds()), user_id)
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:
redis_client = await get_redis()
user_id = await redis_client.getdel(f"refresh:{token}")
log.info("User logged out", extra={"user_id": user_id})
@classmethod
async def refresh_token(cls, token: uuid.UUID) -> Token:
redis_client = await get_redis()
async with async_session_maker() as session:
refresh_session = await redis_client.getdel(f"refresh:{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 redis_client.setex(
f"refresh:{refresh_token}",
int(refresh_token_expires.total_seconds()),
user.id
)
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: uuid.UUID):
# async with async_session_maker() as session:
# await RefreshSessionDAO.delete(session, RefreshSessionModel.user_id == user_id)
# await session.commit()
@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) -> str:
return uuid.uuid4()
class UserService:
@classmethod
async def get_user(cls, user_id: int) -> User:
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")
log.debug("User fetched", extra={"user_id": user_id})
return user_exist
@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,
UserModel.username==user.username
))
if user_exist:
log.warning("User already registered", extra={"email": user.email})
raise HTTPException(status_code=400, detail="User already exists")
print(user.email)
user_db = await UserDAO.add(
session,
UserCreateDB(
**user.model_dump(),
hashed_password=hash_password(user.password),
is_active=True,
is_verified=False,
is_superuser=False
)
)
await session.commit()
await cls.send_verify_email(user_db)
return user_db
@classmethod
async def send_verify_email(cls, user: UserModel):
redis_client = await get_redis()
token = cls._create_email_verification_token()
url = f"{settings.URL}/api/v1/auth/verify/{token}"
await redis_client.setex(f"email:{token}", timedelta(minutes=60), user.id)
EmailTasks.send_verify_email_task.delay(email=user.email, username=user.username, url=url)
@classmethod
def _create_email_verification_token(cls) -> uuid.UUID:
return uuid.uuid4()
@classmethod
async def verify_email(cls, token: uuid.UUID):
redis_client = await get_redis()
async with async_session_maker() as session:
user_id = await redis_client.getdel(f"email:{token}")
if user_id is None:
raise TokenExpiredException
user_exist = await UserDAO.find_one_or_none(session, id=int(user_id))
if user_exist is None:
raise InvalidTokenException
if user_exist.is_verified:
raise HTTPException(status_code=400, detail="Email already verified")
await UserDAO.update(
session,
UserModel.id==int(user_id),
obj_in={"is_verified": True}
)
await session.commit()