diff --git a/app/cache/__init__.py b/app/cache/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/cache/client.py b/app/cache/client.py new file mode 100644 index 0000000..61f4315 --- /dev/null +++ b/app/cache/client.py @@ -0,0 +1,9 @@ +import redis + +from app.core.config import settings + + +redis_client = redis.Redis.from_url( + settings.redis_url, + decode_responses=True, +) diff --git a/app/cache/keys.py b/app/cache/keys.py new file mode 100644 index 0000000..af3749f --- /dev/null +++ b/app/cache/keys.py @@ -0,0 +1,20 @@ +class RedisKeys: + """ + Centralized Redis key generation. + """ + + @staticmethod + def user_email(email: str) -> str: + return f"user:email:{email}" + + @staticmethod + def user_username(username: str) -> str: + return f"user:username:{username}" + + @staticmethod + def blacklist(jti: str) -> str: + return f"blacklist:{jti}" + + @staticmethod + def session(user_id: str) -> str: + return f"session:{user_id}" diff --git a/app/cache/service.py b/app/cache/service.py new file mode 100644 index 0000000..a7f35a1 --- /dev/null +++ b/app/cache/service.py @@ -0,0 +1,81 @@ +from typing import Optional + +from app.cache.client import redis_client +import json +from app.cache.keys import RedisKeys + + +class CacheService: + """ + Redis cache wrapper. + """ + + def set( + self, + key: str, + value: str, + ttl: Optional[int] = None, + ) -> None: + redis_client.set( + key, + value, + ex=ttl, + ) + + def get( + self, + key: str, + ) -> Optional[str]: + return redis_client.get(key) + + def delete( + self, + key: str, + ) -> None: + redis_client.delete(key) + + def exists( + self, + key: str, + ) -> bool: + return bool(redis_client.exists(key)) + + def set_json( + self, + key: str, + value: dict, + ttl: int = 300, + ) -> None: + redis_client.set( + key, + json.dumps(value), + ex=ttl, + ) + + def get_json( + self, + key: str, + ) -> dict | None: + value = redis_client.get(key) + + if value is None: + return None + + return json.loads(value) + + def blacklist_token( + self, + jti: str, + ttl: int, + ): + self.set( + RedisKeys.blacklist(jti), + "1", + ttl, + ) + + def is_blacklisted( + self, + jti: str, + ) -> bool: + return self.exists(RedisKeys.blacklist(jti)) diff --git a/app/core/config.py b/app/core/config.py index 6e5fc59..a6a30a1 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -47,6 +47,15 @@ class Settings(BaseSettings): # ========================= log_level: str + # ========================= + # Redis + # ========================= + + redis_host: str + redis_port: int + redis_db: int + redis_password: str | None = None + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", @@ -79,6 +88,16 @@ def sqlalchemy_database_url(self) -> str: f"/{self.db_name}" ) + @property + def redis_url(self) -> str: + if self.redis_password: + return ( + f"redis://:{self.redis_password}" + f"@{self.redis_host}:{self.redis_port}/{self.redis_db}" + ) + + return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}" + @lru_cache def get_settings() -> Settings: diff --git a/app/core/jwt.py b/app/core/jwt.py index acc4038..9ce3ecc 100644 --- a/app/core/jwt.py +++ b/app/core/jwt.py @@ -25,6 +25,7 @@ def create_access_token( "sub": subject, "exp": expire, "type": "access", + "jti": str(uuid4()), } return jwt.encode( diff --git a/app/modules/auth/dependencies.py b/app/modules/auth/dependencies.py new file mode 100644 index 0000000..2deb16f --- /dev/null +++ b/app/modules/auth/dependencies.py @@ -0,0 +1,33 @@ +from fastapi import Depends +from sqlalchemy.orm import Session + +from app.db.session import get_db + +from app.modules.auth.repository import ( + RefreshTokenRepository, + PasswordResetRepository, + EmailVerificationRepository, +) + +from app.modules.auth.service import AuthService + +from app.modules.roles.repository import RoleRepository +from app.modules.users.repository import UserRepository +from app.providers.factory import get_email_provider + + +def get_auth_service( + db: Session = Depends(get_db), +) -> AuthService: + """ + Dependency provider for AuthService. + """ + + return AuthService( + user_repository=UserRepository(db), + role_repository=RoleRepository(db), + refresh_token_repository=RefreshTokenRepository(db), + password_reset_repository=PasswordResetRepository(db), + email_verification_repository=EmailVerificationRepository(db), + email_provider=get_email_provider(), + ) diff --git a/app/modules/auth/routes.py b/app/modules/auth/routes.py index 695fdb1..76f3e2a 100644 --- a/app/modules/auth/routes.py +++ b/app/modules/auth/routes.py @@ -1,10 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session - -from app.db.session import get_db from app.modules.auth.service import AuthService -from app.modules.roles.repository import RoleRepository -from app.modules.users.repository import UserRepository from app.modules.users.schemas import UserCreate, UserResponse from app.modules.users.models import User from app.shared.dependencies import get_current_user @@ -19,13 +14,9 @@ ResetPasswordRequest, ChangePasswordRequest, ) -from app.modules.auth.repository import ( - RefreshTokenRepository, - PasswordResetRepository, - EmailVerificationRepository, -) from app.modules.auth.schemas import ResendVerificationRequest - +from app.modules.auth.dependencies import get_auth_service +from fastapi import Request router = APIRouter( prefix="/api/v1/auth", @@ -40,18 +31,10 @@ ) def register( user: UserCreate, - db: Session = Depends(get_db), + service: AuthService = Depends(get_auth_service), ): - auth_service = AuthService( - UserRepository(db), - RoleRepository(db), - RefreshTokenRepository(db), - PasswordResetRepository(db), - EmailVerificationRepository(db), - ) - try: - created_user = auth_service.register_user(user) + created_user = service.register_user(user) print(created_user) print(type(created_user)) @@ -84,18 +67,10 @@ def register( ) def login( form_data: OAuth2PasswordRequestForm = Depends(), - db: Session = Depends(get_db), + service: AuthService = Depends(get_auth_service), ): - auth_service = AuthService( - UserRepository(db), - RoleRepository(db), - RefreshTokenRepository(db), - PasswordResetRepository(db), - EmailVerificationRepository(db), - ) - try: - return auth_service.login_user( + return service.login_user( identifier=form_data.username, password=form_data.password, ) @@ -120,18 +95,10 @@ def me( ) def refresh( request: RefreshTokenRequest, - db: Session = Depends(get_db), + service: AuthService = Depends(get_auth_service), ): - auth_service = AuthService( - UserRepository(db), - RoleRepository(db), - RefreshTokenRepository(db), - PasswordResetRepository(db), - EmailVerificationRepository(db), - ) - try: - return auth_service.refresh_tokens(request) + return service.refresh_tokens(request) except ValueError as exc: raise HTTPException( @@ -145,26 +112,27 @@ def refresh( response_model=MessageResponse, ) def logout( - request: RefreshTokenRequest, - db: Session = Depends(get_db), + body: RefreshTokenRequest, + request: Request, + current_user: User = Depends(get_current_user), + service: AuthService = Depends(get_auth_service), ): - auth_service = AuthService( - UserRepository(db), - RoleRepository(db), - RefreshTokenRepository(db), - PasswordResetRepository(db), - EmailVerificationRepository(db), - ) + authorization = request.headers.get("Authorization") - try: - return auth_service.logout(request) - - except ValueError as exc: + if authorization is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail=str(exc), + detail="Missing Authorization header.", ) + access_token = authorization.removeprefix("Bearer ").strip() + + return service.logout( + current_user=current_user, + request=body, + access_token=access_token, + ) + @router.post( "/forgot-password", @@ -172,16 +140,8 @@ def logout( ) def forgot_password( request: ForgotPasswordRequest, - db: Session = Depends(get_db), + service: AuthService = Depends(get_auth_service), ): - service = AuthService( - UserRepository(db), - RoleRepository(db), - RefreshTokenRepository(db), - PasswordResetRepository(db), - EmailVerificationRepository(db), - ) - return service.forgot_password(request) @@ -191,16 +151,8 @@ def forgot_password( ) def reset_password( request: ResetPasswordRequest, - db: Session = Depends(get_db), + service: AuthService = Depends(get_auth_service), ): - service = AuthService( - UserRepository(db), - RoleRepository(db), - RefreshTokenRepository(db), - PasswordResetRepository(db), - EmailVerificationRepository(db), - ) - return service.reset_password(request) @@ -211,16 +163,8 @@ def reset_password( def change_password( request: ChangePasswordRequest, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + service: AuthService = Depends(get_auth_service), ): - service = AuthService( - UserRepository(db), - RoleRepository(db), - RefreshTokenRepository(db), - PasswordResetRepository(db), - EmailVerificationRepository(db), - ) - return service.change_password( current_user, request, @@ -233,22 +177,14 @@ def change_password( ) def verify_email( token: str, - db: Session = Depends(get_db), + service: AuthService = Depends(get_auth_service), ): - service = AuthService( - UserRepository(db), - RoleRepository(db), - RefreshTokenRepository(db), - PasswordResetRepository(db), - EmailVerificationRepository(db), - ) - try: return service.verify_email(token) except ValueError as exc: raise HTTPException( - status_code=400, + status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc), ) @@ -259,14 +195,6 @@ def verify_email( ) def resend_verification( request: ResendVerificationRequest, - db: Session = Depends(get_db), + service: AuthService = Depends(get_auth_service), ): - service = AuthService( - UserRepository(db), - RoleRepository(db), - RefreshTokenRepository(db), - PasswordResetRepository(db), - EmailVerificationRepository(db), - ) - return service.resend_verification(request) diff --git a/app/modules/auth/schemas.py b/app/modules/auth/schemas.py index 43addd5..3b77d00 100644 --- a/app/modules/auth/schemas.py +++ b/app/modules/auth/schemas.py @@ -16,6 +16,11 @@ class RefreshTokenRequest(BaseModel): refresh_token: str +class LogoutRequest(BaseModel): + refresh_token: str + access_token: str + + class MessageResponse(BaseModel): message: str diff --git a/app/modules/auth/service.py b/app/modules/auth/service.py index 774e4ed..cbba00a 100644 --- a/app/modules/auth/service.py +++ b/app/modules/auth/service.py @@ -33,6 +33,8 @@ from app.modules.auth.models import EmailVerificationToken from app.modules.auth.repository import EmailVerificationRepository from app.modules.auth.schemas import ResendVerificationRequest +from app.providers.base import EmailProvider +from app.cache.service import CacheService class AuthService: @@ -47,12 +49,15 @@ def __init__( refresh_token_repository: RefreshTokenRepository, password_reset_repository: PasswordResetRepository, email_verification_repository: EmailVerificationRepository, + email_provider: EmailProvider, ): self.user_repository = user_repository self.role_repository = role_repository self.refresh_token_repository = refresh_token_repository self.password_reset_repository = password_reset_repository self.email_verification_repository = email_verification_repository + self.email_provider = email_provider + self.cache = CacheService() def register_user(self, user_data: UserCreate) -> User: """ @@ -100,8 +105,14 @@ def register_user(self, user_data: UserCreate) -> User: created_user.email, ) - print( - f"\nVERIFY EMAIL:\nhttp://localhost:8000/api/v1/auth/verify-email?token={verification_token}\n" + verification_link = ( + f"http://localhost:8000/api/v1/auth/verify-email" + f"?token={verification_token}" + ) + + self.email_provider.send_verification_email( + created_user.email, + verification_link, ) logger.info( @@ -217,7 +228,9 @@ def refresh_tokens( def logout( self, + current_user: User, request: RefreshTokenRequest, + access_token: str, ) -> MessageResponse: """ Logout user by revoking the refresh token. @@ -231,7 +244,9 @@ def logout( if payload.get("type") != "refresh": raise ValueError("Invalid refresh token.") - stored_token = self.refresh_token_repository.get_by_token(request.refresh_token) + stored_token = self.refresh_token_repository.get_by_token( + request.refresh_token + ) if stored_token is None: raise ValueError("Refresh token not found.") @@ -241,7 +256,32 @@ def logout( self.refresh_token_repository.revoke(stored_token) - return MessageResponse(message="Logged out successfully.") + # Decode access token + access_payload = decode_token(access_token) + + if access_payload is None: + raise ValueError("Invalid access token.") + + jti = access_payload.get("jti") + + if jti is None: + raise ValueError("Missing token identifier.") + + exp = access_payload.get("exp") + + remaining_ttl = max( + 0, + int(exp - datetime.now(timezone.utc).timestamp()), + ) + + self.cache.blacklist_token( + jti, + remaining_ttl, + ) + + return MessageResponse( + message="Logged out successfully." + ) def forgot_password( self, @@ -273,6 +313,15 @@ def forgot_password( user.email, ) + reset_link = ( + f"http://localhost:8000/api/v1/auth/reset-password" f"?token={reset_token}" + ) + + self.email_provider.send_password_reset_email( + user.email, + reset_link, + ) + return ForgotPasswordResponse( message="If the email exists, a password reset link has been sent." ) @@ -409,8 +458,14 @@ def resend_verification( user.email, ) - print( - f"\nVERIFY EMAIL:\nhttp://localhost:8000/api/v1/auth/verify-email?token={verification_token}\n" + verification_link = ( + f"http://localhost:8000/api/v1/auth/verify-email" + f"?token={verification_token}" + ) + + self.email_provider.send_verification_email( + user.email, + verification_link, ) return MessageResponse(message="Verification email sent.") diff --git a/app/modules/users/repository.py b/app/modules/users/repository.py index 4d657c5..ffe8720 100644 --- a/app/modules/users/repository.py +++ b/app/modules/users/repository.py @@ -2,8 +2,10 @@ from sqlalchemy import select from sqlalchemy.orm import Session - +from app.core.logger import logger from app.modules.users.models import User +from app.cache.service import CacheService +from app.cache.keys import RedisKeys class UserRepository: @@ -13,14 +15,61 @@ class UserRepository: def __init__(self, db: Session): self.db = db + self.cache = CacheService() + + def get_by_email( + self, + email: str, + ): + cache_key = RedisKeys.user_email(email) + + cached = self.cache.get_json(cache_key) + + if cached: + logger.info("Cache HIT: %s", cache_key) + return self.db.get(User, UUID(cached["id"])) + + logger.info("Cache MISS: %s", cache_key) - def get_by_email(self, email: str) -> User | None: stmt = select(User).where(User.email == email) - return self.db.execute(stmt).scalar_one_or_none() - def get_by_username(self, username: str) -> User | None: + user = self.db.execute(stmt).scalar_one_or_none() + + if user: + self.cache.set_json( + cache_key, + {"id": str(user.id)}, + ttl=300, + ) + + return user + + def get_by_username( + self, + username: str, + ): + cache_key = RedisKeys.user_username(username) + + cached = self.cache.get_json(cache_key) + + if cached: + logger.info("Cache HIT: %s", cache_key) + return self.db.get(User, UUID(cached["id"])) + + logger.info("Cache MISS: %s", cache_key) + stmt = select(User).where(User.username == username) - return self.db.execute(stmt).scalar_one_or_none() + + user = self.db.execute(stmt).scalar_one_or_none() + + if user: + self.cache.set_json( + cache_key, + {"id": str(user.id)}, + ttl=300, + ) + + return user def get_by_id(self, user_id: UUID | str) -> User | None: stmt = select(User).where(User.id == user_id) @@ -41,5 +90,8 @@ def get_all(self) -> list[User]: def update(self, user: User) -> User: self.db.commit() + self.cache.delete(RedisKeys.user_email(user.email)) + + self.cache.delete(RedisKeys.user_username(user.username)) self.db.refresh(user) return user diff --git a/app/providers/__init__.py b/app/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/providers/base.py b/app/providers/base.py new file mode 100644 index 0000000..1a569f1 --- /dev/null +++ b/app/providers/base.py @@ -0,0 +1,23 @@ +from abc import ABC, abstractmethod + + +class EmailProvider(ABC): + """ + Base interface for email providers. + """ + + @abstractmethod + def send_verification_email( + self, + email: str, + verification_link: str, + ) -> None: + ... + + @abstractmethod + def send_password_reset_email( + self, + email: str, + reset_link: str, + ) -> None: + ... diff --git a/app/providers/console.py b/app/providers/console.py new file mode 100644 index 0000000..8a3713b --- /dev/null +++ b/app/providers/console.py @@ -0,0 +1,30 @@ +from app.providers.base import EmailProvider + + +class ConsoleEmailProvider(EmailProvider): + """ + Development email provider. + Prints links to the console. + """ + + def send_verification_email( + self, + email: str, + verification_link: str, + ) -> None: + print("\n" + "=" * 60) + print("EMAIL VERIFICATION") + print(f"To: {email}") + print(verification_link) + print("=" * 60 + "\n") + + def send_password_reset_email( + self, + email: str, + reset_link: str, + ) -> None: + print("\n" + "=" * 60) + print("PASSWORD RESET") + print(f"To: {email}") + print(reset_link) + print("=" * 60 + "\n") diff --git a/app/providers/factory.py b/app/providers/factory.py new file mode 100644 index 0000000..b310d28 --- /dev/null +++ b/app/providers/factory.py @@ -0,0 +1,13 @@ +from app.providers.console import ConsoleEmailProvider + + +def get_email_provider(): + """ + Return the configured email provider. + + Future: + - Resend + - SMTP + - SendGrid + """ + return ConsoleEmailProvider() diff --git a/requirements.txt b/requirements.txt index c98b9e7..4d6e7ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,18 +2,20 @@ alembic==1.18.5 annotated-doc==0.0.4 annotated-types==0.7.0 anyio==4.14.1 -bcrypt==4.1.0 -black==23.11.0 +bcrypt==4.0.1 certifi==2026.6.17 cffi==2.0.0 click==8.4.2 colorama==0.4.6 +coverage==7.15.0 cryptography==49.0.0 +dnspython==2.8.0 ecdsa==0.19.2 email-validator==2.3.0 fastapi==0.139.0 greenlet==3.5.3 h11==0.16.0 +hiredis==3.4.0 httpcore==1.0.9 httpx==0.28.1 idna==3.18 @@ -31,13 +33,16 @@ pydantic-settings==2.14.2 pydantic_core==2.46.4 Pygments==2.20.0 pytest==9.1.1 +pytest-cov==7.1.0 python-dotenv==1.2.2 python-jose==3.5.0 python-multipart==0.0.32 +redis==8.0.1 rsa==4.9.1 ruff==0.15.20 six==1.17.0 SQLAlchemy==2.0.51 +starlette==1.3.1 typing-inspection==0.4.2 typing_extensions==4.16.0 uvicorn==0.50.0 diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..1348b43 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,21 @@ +import uuid + +from app.cache.service import CacheService + + +def test_cache_service(): + cache = CacheService() + + key = f"test:{uuid.uuid4()}" + + cache.set_json( + key, + {"hello": "world"}, + ttl=60, + ) + + assert cache.get_json(key)["hello"] == "world" + + cache.delete(key) + + assert cache.get_json(key) is None diff --git a/tests/test_logout.py b/tests/test_logout.py index 9456cbd..50ee60a 100644 --- a/tests/test_logout.py +++ b/tests/test_logout.py @@ -30,9 +30,13 @@ def test_logout(): ) refresh_token = login.json()["refresh_token"] + access_token = login.json()["access_token"] response = client.post( "/api/v1/auth/logout", + headers={ + "Authorization": f"Bearer {access_token}", + }, json={ "refresh_token": refresh_token, }, diff --git a/tests/test_redis.py b/tests/test_redis.py new file mode 100644 index 0000000..a2fb98f --- /dev/null +++ b/tests/test_redis.py @@ -0,0 +1,19 @@ +from app.cache.service import CacheService + + +def test_redis_connection(): + cache = CacheService() + + cache.set( + "health", + "ok", + ttl=10, + ) + + assert cache.get("health") == "ok" + + assert cache.exists("health") + + cache.delete("health") + + assert cache.get("health") is None