From 45d31369089e782366bdf6748449d460ba4c65cb Mon Sep 17 00:00:00 2001 From: CarlosOchoa8 Date: Sun, 13 Jul 2025 20:53:53 -0600 Subject: [PATCH 1/2] feat(backend): add logic for tracking failed login attempts --- backend/app/services/auth_service.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index fa8571c..608dca3 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -4,7 +4,7 @@ from uuid import UUID from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, update +from sqlalchemy import select, update, Null from app.models.user import User, UserStatus from app.schemas.auth import LoginRequest, RegisterRequest, AuthUser, TokenResponse @@ -318,13 +318,26 @@ async def _handle_failed_login( :param user: User object if it exists, otherwise None. :return: None """ + # TODO: Define where place redis key cleanup + # https://github.com/Anvoria/smithy/issues/3 if not user: # Don't reveal if user exists return - # TODO: Implement logic to track failed login attempts - # https://github.com/Anvoria/smithy/issues/3 - pass + login_key = f"login_attempts:{email}" + + login_data = await redis_client.get(key=login_key) or { + "attempts": 0, + "is_locked": False, + } + login_data["attempts"] += 1 + + if login_data.get("attempts") >= 5: + login_data["is_locked"] = True + + await redis_client.set( + key=login_key, value=login_data, expire=timedelta(minutes=15) + ) async def _update_login_info(self, user: User) -> None: """ From d076b07714ff4e1f8664f579d277b82c69800cf2 Mon Sep 17 00:00:00 2001 From: CarlosOchoa8 Date: Sun, 13 Jul 2025 20:54:38 -0600 Subject: [PATCH 2/2] feat(backend): add user account locking and unlocking functionalities --- backend/app/services/auth_service.py | 35 ++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 608dca3..4d0f68c 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -335,10 +335,45 @@ async def _handle_failed_login( if login_data.get("attempts") >= 5: login_data["is_locked"] = True + await self._lock_user_account(email=email) + await redis_client.set( key=login_key, value=login_data, expire=timedelta(minutes=15) ) + async def _lock_user_account(self, email: str) -> None: + """ + Update user's locked field if passed max login attempts. + :param email: User email to update. + :return: None. + """ + locked_until = datetime.now(UTC) + timedelta(minutes=15) + + stmt = ( + update(User) + .where(User.email == email) + .values(is_locked=True, locked_until=locked_until) + ) + + await self.db.execute(stmt) + await self.db.commit() + + async def _unlock_user_account(self, email: str) -> None: + """ + Update user's locked field. + :param email: User email to update. + :return: None. + """ + stmt = ( + update(User) + .where(User.email == email) + .values(is_locked=False, locked_until=Null) + ) + + await redis_client.delete(key=f"login_attempts:{email}") + await self.db.execute(stmt) + await self.db.commit() + async def _update_login_info(self, user: User) -> None: """ Update user's last login and activity timestamps, and increment login count.