From 423ce8bc62a977ba0bdf3ba96e106da1ef5aa67c Mon Sep 17 00:00:00 2001 From: lorsan Date: Tue, 6 Jan 2026 23:42:12 +0300 Subject: [PATCH] refractor redis service --- backend/app/config.py | 1 + backend/app/services/email_service.py | 2 +- backend/app/services/redis_service.py | 72 +++++++++++++++++++++++++++ backend/app/users/router.py | 24 ++++++--- backend/app/users/service.py | 37 +++++++------- 5 files changed, 109 insertions(+), 27 deletions(-) create mode 100644 backend/app/services/redis_service.py diff --git a/backend/app/config.py b/backend/app/config.py index 7f53093..023bb4d 100755 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -21,6 +21,7 @@ class Settings(BaseSettings): SECRET_KEY: str ALGORITHM: str ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 + EMAIL_TOKEN_EXPIRE_MINUTES: int = 60 REFRESH_TOKEN_EXPIRE_DAYS: int = 30 SMTP_SERVER: str diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 277fb70..1c7d095 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -16,7 +16,7 @@ class EmailService: template_path="confirm_email.html", username=username, url=url, - expire_minutes=60, + expire_minutes=settings.EMAIL_TOKEN_EXPIRE_MINUTES, company_name=settings.COMPANY_NAME ) body = EmailClient.render( diff --git a/backend/app/services/redis_service.py b/backend/app/services/redis_service.py new file mode 100644 index 0000000..b3a7e33 --- /dev/null +++ b/backend/app/services/redis_service.py @@ -0,0 +1,72 @@ +import logging +import uuid + +from app.utils.redis import get_redis + +log = logging.getLogger(__name__) + + +class RefreshTokenStorage: + PREFIX: str = "refresh" + + @classmethod + async def save_token(cls, token: uuid.UUID, user_id: int, ttl: int): + redis_client = await get_redis() + + await redis_client.setex(f"{cls.PREFIX}:{token}", ttl, user_id) + await redis_client.sadd(f"user:{user_id}:{cls.PREFIX}", str(token)) + await redis_client.expire(f"user:{user_id}:{cls.PREFIX}", ttl+3600) + + log.info("Save new refresh token from redis", extra={"user_id": user_id, "token": token}) + + + @classmethod + async def getdel_token(cls, token: uuid.UUID) -> int: + redis_client = await get_redis() + user_id = await redis_client.getdel(f"{cls.PREFIX}:{token}") + await redis_client.srem(f"user:{user_id}:{cls.PREFIX}", str(token)) + log.info("Remove token", extra={"user_id": user_id, "token": token}) + return user_id + + + @classmethod + async def get_token(cls, token: uuid.UUID) -> int: + redis_client = await get_redis() + user_id = await redis_client.get(f"{cls.PREFIX}:{token}") + log.debug("User_id fetched from refresh token", extra={"user_id": user_id, "token": token}) + return user_id + + + @classmethod + async def abort_all_tokens(cls, user_id: int): + redis_client = await get_redis() + log.debug("Start abort all tokens", extra={"user_id": user_id}) + + tokens = await redis_client.smembers(f"user:{user_id}:{cls.PREFIX}") + + for token in tokens: + await redis_client.delete(f"{cls.PREFIX}:{token}") + + await redis_client.delete(f"user:{user_id}:{cls.PREFIX}") + + log.info("Successfully abort all tokens", extra={"user_id": user_id}) + + + +class EmailTokenStorage: + PREFIX: str = "email" + + @classmethod + async def save_token(cls, token: uuid.UUID, user_id: int, ttl: int): + redis_client = await get_redis() + + await redis_client.setex(f"{cls.PREFIX}:{token}", ttl, user_id) + + log.info("Save new refresh token from redis", extra={"user_id": user_id, "token": token}) + + + @classmethod + async def getdel_token(cls, token: uuid.UUID) -> int: + redis_client = await get_redis() + log.debug("User_id fetched from email token", extra={"token": token}) + return await redis_client.getdel(f"{cls.PREFIX}:{token}") \ No newline at end of file diff --git a/backend/app/users/router.py b/backend/app/users/router.py index 89453f9..90befd0 100755 --- a/backend/app/users/router.py +++ b/backend/app/users/router.py @@ -1,7 +1,8 @@ +from typing import Dict import logging import uuid -from fastapi import APIRouter, status, Response, Depends, Request +from fastapi import APIRouter, status, Response, Depends, Request, HTTPException from fastapi.security import OAuth2PasswordRequestForm from app.users.schemas import UserCreate, User, Token @@ -16,18 +17,20 @@ 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): +async def verify_email(token: uuid.UUID) -> Dict: await UserService.verify_email(token) - return {"status": True} + return {"status": True, "message": "User successfully verified email"} + +@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") -@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"} @@ -72,9 +75,14 @@ async def refresh_token(request: Request, response: Response) -> Token: return new_token @auth_router.post("/logout") -async def logout(request: Request, response: Response, user: UserModel = Depends(get_current_user)): +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"} \ No newline at end of file + 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"} \ No newline at end of file diff --git a/backend/app/users/service.py b/backend/app/users/service.py index 7d5f913..73a6b2d 100644 --- a/backend/app/users/service.py +++ b/backend/app/users/service.py @@ -8,6 +8,7 @@ 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.users.models import UserModel @@ -23,29 +24,26 @@ 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) + 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: - redis_client = await get_redis() - user_id = await redis_client.getdel(f"refresh:{token}") + 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: - redis_client = await get_redis() async with async_session_maker() as session: - refresh_session = await redis_client.getdel(f"refresh:{token}") + refresh_session = await RefreshTokenStorage.getdel_token(token) if refresh_session is None: log.warning("Refresh token not found") @@ -61,10 +59,10 @@ class AuthService: 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 RefreshTokenStorage.save_token( + refresh_token, + user.id, + int(refresh_token_expires.total_seconds()) ) await session.commit() @@ -87,11 +85,9 @@ class AuthService: 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 + 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: @@ -105,7 +101,7 @@ class AuthService: return f'Bearer {encoded_jwt}' @classmethod - def _create_refresh_token(cls) -> str: + def _create_refresh_token(cls) -> uuid.UUID: return uuid.uuid4() @@ -159,8 +155,13 @@ class UserService: token = cls._create_email_verification_token() url = f"{settings.URL}/api/v1/auth/verify/{token}" + email_token_expires = timedelta(minutes=settings.EMAIL_TOKEN_EXPIRE_MINUTES) - await redis_client.setex(f"email:{token}", timedelta(minutes=60), user.id) + await EmailTokenStorage.save_token( + token, + user.id, + int(email_token_expires.total_seconds()) + ) EmailTasks.send_verify_email_task.delay(email=user.email, username=user.username, url=url) @@ -173,7 +174,7 @@ class UserService: 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}") + user_id = await EmailTokenStorage.getdel_token(token) if user_id is None: raise TokenExpiredException