Refactor auth service to improve refresh token revocation handling and logging

This commit is contained in:
JSC
2025-07-25 18:23:19 +02:00
parent e456d34897
commit 45ba28af52
2 changed files with 80 additions and 47 deletions

View File

@@ -8,12 +8,9 @@ from app.core.config import settings
from app.core.dependencies import get_auth_service, get_current_active_user from app.core.dependencies import get_auth_service, get_current_active_user
from app.core.logging import get_logger from app.core.logging import get_logger
from app.models.user import User from app.models.user import User
from app.schemas.auth import ( from app.schemas.auth import UserLoginRequest, UserRegisterRequest, UserResponse
UserLoginRequest,
UserRegisterRequest,
UserResponse,
)
from app.services.auth import AuthService from app.services.auth import AuthService
from app.utils.auth import JWTUtils
router = APIRouter() router = APIRouter()
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -31,11 +28,11 @@ async def register(
"""Register a new user account.""" """Register a new user account."""
try: try:
auth_response = await auth_service.register(request) auth_response = await auth_service.register(request)
# Create and store refresh token - need to get User object from service # Create and store refresh token - need to get User object from service
user = await auth_service.get_current_user(auth_response.user.id) user = await auth_service.get_current_user(auth_response.user.id)
refresh_token = await auth_service.create_and_store_refresh_token(user) refresh_token = await auth_service.create_and_store_refresh_token(user)
# Set HTTP-only cookies for both tokens # Set HTTP-only cookies for both tokens
response.set_cookie( response.set_cookie(
key="access_token", key="access_token",
@@ -48,12 +45,15 @@ async def register(
response.set_cookie( response.set_cookie(
key="refresh_token", key="refresh_token",
value=refresh_token, value=refresh_token,
max_age=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60, # Convert days to seconds max_age=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
* 24
* 60
* 60, # Convert days to seconds
httponly=True, httponly=True,
secure=settings.COOKIE_SECURE, secure=settings.COOKIE_SECURE,
samesite=settings.COOKIE_SAMESITE, samesite=settings.COOKIE_SAMESITE,
) )
# Return only user data, tokens are now in cookies # Return only user data, tokens are now in cookies
return auth_response.user return auth_response.user
except HTTPException: except HTTPException:
@@ -75,11 +75,11 @@ async def login(
"""Authenticate a user and return access token.""" """Authenticate a user and return access token."""
try: try:
auth_response = await auth_service.login(request) auth_response = await auth_service.login(request)
# Create and store refresh token - need to get User object from service # Create and store refresh token - need to get User object from service
user = await auth_service.get_current_user(auth_response.user.id) user = await auth_service.get_current_user(auth_response.user.id)
refresh_token = await auth_service.create_and_store_refresh_token(user) refresh_token = await auth_service.create_and_store_refresh_token(user)
# Set HTTP-only cookies for both tokens # Set HTTP-only cookies for both tokens
response.set_cookie( response.set_cookie(
key="access_token", key="access_token",
@@ -92,12 +92,15 @@ async def login(
response.set_cookie( response.set_cookie(
key="refresh_token", key="refresh_token",
value=refresh_token, value=refresh_token,
max_age=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60, # Convert days to seconds max_age=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
* 24
* 60
* 60, # Convert days to seconds
httponly=True, httponly=True,
secure=settings.COOKIE_SECURE, secure=settings.COOKIE_SECURE,
samesite=settings.COOKIE_SAMESITE, samesite=settings.COOKIE_SAMESITE,
) )
# Return only user data, tokens are now in cookies # Return only user data, tokens are now in cookies
return auth_response.user return auth_response.user
except HTTPException: except HTTPException:
@@ -139,10 +142,10 @@ async def refresh_token(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="No refresh token provided", detail="No refresh token provided",
) )
# Get new access token # Get new access token
token_response = await auth_service.refresh_access_token(refresh_token) token_response = await auth_service.refresh_access_token(refresh_token)
# Set new access token cookie # Set new access token cookie
response.set_cookie( response.set_cookie(
key="access_token", key="access_token",
@@ -152,7 +155,7 @@ async def refresh_token(
secure=settings.COOKIE_SECURE, secure=settings.COOKIE_SECURE,
samesite=settings.COOKIE_SAMESITE, samesite=settings.COOKIE_SAMESITE,
) )
return {"message": "Token refreshed successfully"} return {"message": "Token refreshed successfully"}
except HTTPException: except HTTPException:
raise raise
@@ -167,32 +170,56 @@ async def refresh_token(
@router.post("/logout") @router.post("/logout")
async def logout( async def logout(
response: Response, response: Response,
current_user: Annotated[User, Depends(get_current_active_user)], access_token: Annotated[str | None, Cookie()],
refresh_token: Annotated[str | None, Cookie()],
auth_service: Annotated[AuthService, Depends(get_auth_service)], auth_service: Annotated[AuthService, Depends(get_auth_service)],
) -> dict[str, str]: ) -> dict[str, str]:
"""Logout endpoint - clears cookies and revokes refresh token.""" """Logout endpoint - clears cookies and revokes refresh token."""
try: user = None
# Revoke refresh token from database
await auth_service.revoke_refresh_token(current_user) # Try to get user from access token first
if access_token:
# Clear both cookies try:
response.delete_cookie( payload = JWTUtils.decode_access_token(access_token)
key="access_token", user_id_str = payload.get("sub")
httponly=True, if user_id_str:
secure=settings.COOKIE_SECURE, user_id = int(user_id_str)
samesite=settings.COOKIE_SAMESITE, user = await auth_service.get_current_user(user_id)
) logger.info("Found user from access token: %s", user.email)
response.delete_cookie( except (HTTPException, Exception) as e:
key="refresh_token", logger.info("Access token validation failed: %s", str(e))
httponly=True,
secure=settings.COOKIE_SECURE, # If no user found, try refresh token
samesite=settings.COOKIE_SAMESITE, if not user and refresh_token:
) try:
payload = JWTUtils.decode_refresh_token(refresh_token)
return {"message": "Successfully logged out"} user_id_str = payload.get("sub")
except Exception as e: if user_id_str:
logger.exception("Logout failed") user_id = int(user_id_str)
raise HTTPException( user = await auth_service.get_current_user(user_id)
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, logger.info("Found user from refresh token: %s", user.email)
detail="Logout failed", except (HTTPException, Exception) as e:
) from e logger.info("Refresh token validation failed: %s", str(e))
# If we found a user, revoke their refresh token
if user:
await auth_service.revoke_refresh_token(user)
logger.info("Successfully revoked refresh token for user: %s", user.email)
else:
logger.info("No user found, skipping token revocation")
# Always clear both cookies regardless of token validity
response.delete_cookie(
key="access_token",
httponly=True,
secure=settings.COOKIE_SECURE,
samesite=settings.COOKIE_SAMESITE,
)
response.delete_cookie(
key="refresh_token",
httponly=True,
secure=settings.COOKIE_SECURE,
samesite=settings.COOKIE_SAMESITE,
)
return {"message": "Successfully logged out"}

View File

@@ -231,11 +231,17 @@ class AuthService:
async def revoke_refresh_token(self, user: User) -> None: async def revoke_refresh_token(self, user: User) -> None:
"""Revoke a user's refresh token.""" """Revoke a user's refresh token."""
user.refresh_token_hash = None try:
user.refresh_token_expires_at = None # Use the repository to update the user to ensure proper session handling
self.session.add(user) update_data = {
await self.session.commit() "refresh_token_hash": None,
logger.info("Refresh token revoked for user: %s", user.email) "refresh_token_expires_at": None,
}
await self.user_repo.update(user, update_data)
logger.info("Refresh token revoked for user: %s", user.email)
except Exception:
logger.exception("Failed to revoke refresh token for user: %s", user.email)
raise
async def create_user_response(self, user: User) -> UserResponse: async def create_user_response(self, user: User) -> UserResponse:
"""Create a user response from a user model.""" """Create a user response from a user model."""