- **Authentication Refactor:** Introduce `_create_login_session` utility to streamline session creation for login and OAuth flows. - **Configurations:** Dynamically set app name in PostgreSQL connection (`application_name`) and adjust token expiration settings (`expires_in`) based on system configuration.
592 lines
20 KiB
Python
Executable File
592 lines
20 KiB
Python
Executable File
# app/api/routes/auth.py
|
|
import logging
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
|
from fastapi.security import OAuth2PasswordRequestForm
|
|
from slowapi import Limiter
|
|
from slowapi.util import get_remote_address
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.dependencies.auth import get_current_user
|
|
from app.core.auth import TokenExpiredError, TokenInvalidError, decode_token
|
|
from app.core.auth import get_password_hash
|
|
from app.core.database import get_db
|
|
from app.core.exceptions import (
|
|
AuthenticationError as AuthError,
|
|
DatabaseError,
|
|
ErrorCode
|
|
)
|
|
from app.crud.session import session as session_crud
|
|
from app.crud.user import user as user_crud
|
|
from app.models.user import User
|
|
from app.schemas.common import MessageResponse
|
|
from app.schemas.sessions import SessionCreate, LogoutRequest
|
|
from app.schemas.users import (
|
|
UserCreate,
|
|
UserResponse,
|
|
Token,
|
|
LoginRequest,
|
|
RefreshTokenRequest,
|
|
PasswordResetRequest,
|
|
PasswordResetConfirm
|
|
)
|
|
from app.services.auth_service import AuthService, AuthenticationError
|
|
from app.services.email_service import email_service
|
|
from app.utils.device import extract_device_info
|
|
from app.utils.security import create_password_reset_token, verify_password_reset_token
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Initialize limiter for this router
|
|
limiter = Limiter(key_func=get_remote_address)
|
|
|
|
# Use higher rate limits in test environment
|
|
IS_TEST = os.getenv("IS_TEST", "False") == "True"
|
|
RATE_MULTIPLIER = 100 if IS_TEST else 1
|
|
|
|
|
|
async def _create_login_session(
|
|
db: AsyncSession,
|
|
request: Request,
|
|
user: User,
|
|
tokens: Token,
|
|
login_type: str = "login"
|
|
) -> None:
|
|
"""
|
|
Create a session record for successful login.
|
|
|
|
This is a best-effort operation - login succeeds even if session creation fails.
|
|
|
|
Args:
|
|
db: Database session
|
|
request: FastAPI request object for device info extraction
|
|
user: Authenticated user
|
|
tokens: Token object containing refresh token with JTI
|
|
login_type: Type of login for logging ("login" or "oauth")
|
|
"""
|
|
try:
|
|
device_info = extract_device_info(request)
|
|
|
|
# Decode refresh token to get JTI and expiration
|
|
refresh_payload = decode_token(tokens.refresh_token, verify_type="refresh")
|
|
|
|
session_data = SessionCreate(
|
|
user_id=user.id,
|
|
refresh_token_jti=refresh_payload.jti,
|
|
device_name=device_info.device_name or "API Client",
|
|
device_id=device_info.device_id,
|
|
ip_address=device_info.ip_address,
|
|
user_agent=device_info.user_agent,
|
|
last_used_at=datetime.now(timezone.utc),
|
|
expires_at=datetime.fromtimestamp(refresh_payload.exp, tz=timezone.utc),
|
|
location_city=device_info.location_city,
|
|
location_country=device_info.location_country,
|
|
)
|
|
|
|
await session_crud.create_session(db, obj_in=session_data)
|
|
|
|
logger.info(
|
|
f"{login_type.capitalize()} successful: {user.email} from {device_info.device_name} "
|
|
f"(IP: {device_info.ip_address})"
|
|
)
|
|
except Exception as session_err:
|
|
# Log but don't fail login if session creation fails
|
|
logger.error(f"Failed to create session for {user.email}: {str(session_err)}", exc_info=True)
|
|
|
|
|
|
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED, operation_id="register")
|
|
@limiter.limit(f"{5 * RATE_MULTIPLIER}/minute")
|
|
async def register_user(
|
|
request: Request,
|
|
user_data: UserCreate,
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> Any:
|
|
"""
|
|
Register a new user.
|
|
|
|
Returns:
|
|
The created user information.
|
|
"""
|
|
try:
|
|
user = await AuthService.create_user(db, user_data)
|
|
return user
|
|
except AuthenticationError as e:
|
|
# SECURITY: Don't reveal if email exists - generic error message
|
|
logger.warning(f"Registration failed: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Registration failed. Please check your information and try again."
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error during registration: {str(e)}", exc_info=True)
|
|
raise DatabaseError(
|
|
message="An unexpected error occurred. Please try again later.",
|
|
error_code=ErrorCode.INTERNAL_ERROR
|
|
)
|
|
|
|
|
|
@router.post("/login", response_model=Token, operation_id="login")
|
|
@limiter.limit(f"{10 * RATE_MULTIPLIER}/minute")
|
|
async def login(
|
|
request: Request,
|
|
login_data: LoginRequest,
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> Any:
|
|
"""
|
|
Login with username and password.
|
|
|
|
Creates a new session for this device.
|
|
|
|
Returns:
|
|
Access and refresh tokens.
|
|
"""
|
|
try:
|
|
# Attempt to authenticate the user
|
|
user = await AuthService.authenticate_user(db, login_data.email, login_data.password)
|
|
|
|
# Explicitly check for None result and raise correct exception
|
|
if user is None:
|
|
logger.warning(f"Invalid login attempt for: {login_data.email}")
|
|
raise AuthError(
|
|
message="Invalid email or password",
|
|
error_code=ErrorCode.INVALID_CREDENTIALS
|
|
)
|
|
|
|
# User is authenticated, generate tokens
|
|
tokens = AuthService.create_tokens(user)
|
|
|
|
# Create session record (best-effort, doesn't fail login)
|
|
await _create_login_session(db, request, user, tokens, login_type="login")
|
|
|
|
return tokens
|
|
|
|
except AuthenticationError as e:
|
|
# Handle specific authentication errors like inactive accounts
|
|
logger.warning(f"Authentication failed: {str(e)}")
|
|
raise AuthError(
|
|
message=str(e),
|
|
error_code=ErrorCode.INVALID_CREDENTIALS
|
|
)
|
|
except AuthError:
|
|
# Re-raise custom auth exceptions without modification
|
|
raise
|
|
except Exception as e:
|
|
# Handle unexpected errors
|
|
logger.error(f"Unexpected error during login: {str(e)}", exc_info=True)
|
|
raise DatabaseError(
|
|
message="An unexpected error occurred. Please try again later.",
|
|
error_code=ErrorCode.INTERNAL_ERROR
|
|
)
|
|
|
|
|
|
@router.post("/login/oauth", response_model=Token, operation_id='login_oauth')
|
|
@limiter.limit("10/minute")
|
|
async def login_oauth(
|
|
request: Request,
|
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> Any:
|
|
"""
|
|
OAuth2-compatible login endpoint, used by the OpenAPI UI.
|
|
|
|
Creates a new session for this device.
|
|
|
|
Returns:
|
|
Access and refresh tokens.
|
|
"""
|
|
try:
|
|
user = await AuthService.authenticate_user(db, form_data.username, form_data.password)
|
|
|
|
if user is None:
|
|
raise AuthError(
|
|
message="Invalid email or password",
|
|
error_code=ErrorCode.INVALID_CREDENTIALS
|
|
)
|
|
|
|
# Generate tokens
|
|
tokens = AuthService.create_tokens(user)
|
|
|
|
# Create session record (best-effort, doesn't fail login)
|
|
await _create_login_session(db, request, user, tokens, login_type="oauth")
|
|
|
|
# Return full token response with user data
|
|
return tokens
|
|
except AuthenticationError as e:
|
|
logger.warning(f"OAuth authentication failed: {str(e)}")
|
|
raise AuthError(
|
|
message=str(e),
|
|
error_code=ErrorCode.INVALID_CREDENTIALS
|
|
)
|
|
except AuthError:
|
|
# Re-raise custom auth exceptions without modification
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error during OAuth login: {str(e)}", exc_info=True)
|
|
raise DatabaseError(
|
|
message="An unexpected error occurred. Please try again later.",
|
|
error_code=ErrorCode.INTERNAL_ERROR
|
|
)
|
|
|
|
|
|
@router.post("/refresh", response_model=Token, operation_id="refresh_token")
|
|
@limiter.limit("30/minute")
|
|
async def refresh_token(
|
|
request: Request,
|
|
refresh_data: RefreshTokenRequest,
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> Any:
|
|
"""
|
|
Refresh access token using a refresh token.
|
|
|
|
Validates that the session is still active before issuing new tokens.
|
|
|
|
Returns:
|
|
New access and refresh tokens.
|
|
"""
|
|
try:
|
|
# Decode the refresh token to get the JTI
|
|
refresh_payload = decode_token(refresh_data.refresh_token, verify_type="refresh")
|
|
|
|
# Check if session exists and is active
|
|
session = await session_crud.get_active_by_jti(db, jti=refresh_payload.jti)
|
|
|
|
if not session:
|
|
logger.warning(f"Refresh token used for inactive or non-existent session: {refresh_payload.jti}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Session has been revoked. Please log in again.",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
# Generate new tokens
|
|
tokens = await AuthService.refresh_tokens(db, refresh_data.refresh_token)
|
|
|
|
# Decode new refresh token to get new JTI
|
|
new_refresh_payload = decode_token(tokens.refresh_token, verify_type="refresh")
|
|
|
|
# Update session with new refresh token JTI and expiration
|
|
try:
|
|
await session_crud.update_refresh_token(
|
|
db,
|
|
session=session,
|
|
new_jti=new_refresh_payload.jti,
|
|
new_expires_at=datetime.fromtimestamp(new_refresh_payload.exp, tz=timezone.utc)
|
|
)
|
|
except Exception as session_err:
|
|
logger.error(f"Failed to update session {session.id}: {str(session_err)}", exc_info=True)
|
|
# Continue anyway - tokens are already issued
|
|
|
|
return tokens
|
|
|
|
except TokenExpiredError:
|
|
logger.warning("Token refresh failed: Token expired")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Refresh token has expired. Please log in again.",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
except TokenInvalidError:
|
|
logger.warning("Token refresh failed: Invalid token")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid refresh token",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
except HTTPException:
|
|
# Re-raise HTTP exceptions (like session revoked)
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error during token refresh: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="An unexpected error occurred. Please try again later."
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/password-reset/request",
|
|
response_model=MessageResponse,
|
|
status_code=status.HTTP_200_OK,
|
|
summary="Request Password Reset",
|
|
description="""
|
|
Request a password reset link.
|
|
|
|
An email will be sent with a reset link if the email exists.
|
|
Always returns success to prevent email enumeration.
|
|
|
|
**Rate Limit**: 3 requests/minute
|
|
""",
|
|
operation_id="request_password_reset"
|
|
)
|
|
@limiter.limit("3/minute")
|
|
async def request_password_reset(
|
|
request: Request,
|
|
reset_request: PasswordResetRequest,
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> Any:
|
|
"""
|
|
Request a password reset.
|
|
|
|
Sends an email with a password reset link.
|
|
Always returns success to prevent email enumeration.
|
|
"""
|
|
try:
|
|
# Look up user by email
|
|
user = await user_crud.get_by_email(db, email=reset_request.email)
|
|
|
|
# Only send email if user exists and is active
|
|
if user and user.is_active:
|
|
# Generate reset token
|
|
reset_token = create_password_reset_token(user.email)
|
|
|
|
# Send password reset email
|
|
await email_service.send_password_reset_email(
|
|
to_email=user.email,
|
|
reset_token=reset_token,
|
|
user_name=user.first_name
|
|
)
|
|
logger.info(f"Password reset requested for {user.email}")
|
|
else:
|
|
# Log attempt but don't reveal if email exists
|
|
logger.warning(f"Password reset requested for non-existent or inactive email: {reset_request.email}")
|
|
|
|
# Always return success to prevent email enumeration
|
|
return MessageResponse(
|
|
success=True,
|
|
message="If your email is registered, you will receive a password reset link shortly"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error processing password reset request: {str(e)}", exc_info=True)
|
|
# Still return success to prevent information leakage
|
|
return MessageResponse(
|
|
success=True,
|
|
message="If your email is registered, you will receive a password reset link shortly"
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/password-reset/confirm",
|
|
response_model=MessageResponse,
|
|
status_code=status.HTTP_200_OK,
|
|
summary="Confirm Password Reset",
|
|
description="""
|
|
Reset password using a token from email.
|
|
|
|
**Rate Limit**: 5 requests/minute
|
|
""",
|
|
operation_id="confirm_password_reset"
|
|
)
|
|
@limiter.limit("5/minute")
|
|
async def confirm_password_reset(
|
|
request: Request,
|
|
reset_confirm: PasswordResetConfirm,
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> Any:
|
|
"""
|
|
Confirm password reset with token.
|
|
|
|
Verifies the token and updates the user's password.
|
|
"""
|
|
try:
|
|
# Verify the reset token
|
|
email = verify_password_reset_token(reset_confirm.token)
|
|
|
|
if not email:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid or expired password reset token"
|
|
)
|
|
|
|
# Look up user
|
|
user = await user_crud.get_by_email(db, email=email)
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
if not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="User account is inactive"
|
|
)
|
|
|
|
# Update password
|
|
user.password_hash = get_password_hash(reset_confirm.new_password)
|
|
db.add(user)
|
|
await db.commit()
|
|
|
|
# SECURITY: Invalidate all existing sessions after password reset
|
|
# This prevents stolen sessions from being used after password change
|
|
from app.crud.session import session as session_crud
|
|
try:
|
|
deactivated_count = await session_crud.deactivate_all_user_sessions(
|
|
db,
|
|
user_id=str(user.id)
|
|
)
|
|
logger.info(f"Password reset successful for {user.email}, invalidated {deactivated_count} sessions")
|
|
except Exception as session_error:
|
|
# Log but don't fail password reset if session invalidation fails
|
|
logger.error(f"Failed to invalidate sessions after password reset: {str(session_error)}")
|
|
|
|
return MessageResponse(
|
|
success=True,
|
|
message="Password has been reset successfully. All devices have been logged out for security. You can now log in with your new password."
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error confirming password reset: {str(e)}", exc_info=True)
|
|
await db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="An error occurred while resetting your password"
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/logout",
|
|
response_model=MessageResponse,
|
|
status_code=status.HTTP_200_OK,
|
|
summary="Logout from Current Device",
|
|
description="""
|
|
Logout from the current device only.
|
|
|
|
Other devices will remain logged in.
|
|
|
|
Requires the refresh token to identify which session to terminate.
|
|
|
|
**Rate Limit**: 10 requests/minute
|
|
""",
|
|
operation_id="logout"
|
|
)
|
|
@limiter.limit("10/minute")
|
|
async def logout(
|
|
request: Request,
|
|
logout_request: LogoutRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> Any:
|
|
"""
|
|
Logout from current device by deactivating the session.
|
|
|
|
Args:
|
|
logout_request: Contains the refresh token for this session
|
|
current_user: Current authenticated user
|
|
db: Database session
|
|
|
|
Returns:
|
|
Success message
|
|
"""
|
|
try:
|
|
# Decode refresh token to get JTI
|
|
try:
|
|
refresh_payload = decode_token(logout_request.refresh_token, verify_type="refresh")
|
|
except (TokenExpiredError, TokenInvalidError) as e:
|
|
# Even if token is expired/invalid, try to deactivate session
|
|
logger.warning(f"Logout with invalid/expired token: {str(e)}")
|
|
# Don't fail - return success anyway
|
|
return MessageResponse(
|
|
success=True,
|
|
message="Logged out successfully"
|
|
)
|
|
|
|
# Find the session by JTI
|
|
session = await session_crud.get_by_jti(db, jti=refresh_payload.jti)
|
|
|
|
if session:
|
|
# Verify session belongs to current user (security check)
|
|
if str(session.user_id) != str(current_user.id):
|
|
logger.warning(
|
|
f"User {current_user.id} attempted to logout session {session.id} "
|
|
f"belonging to user {session.user_id}"
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You can only logout your own sessions"
|
|
)
|
|
|
|
# Deactivate the session
|
|
await session_crud.deactivate(db, session_id=str(session.id))
|
|
|
|
logger.info(
|
|
f"User {current_user.id} logged out from {session.device_name} "
|
|
f"(session {session.id})"
|
|
)
|
|
else:
|
|
# Session not found - maybe already deleted or never existed
|
|
# Return success anyway (idempotent)
|
|
logger.info(f"Logout requested for non-existent session (JTI: {refresh_payload.jti})")
|
|
|
|
return MessageResponse(
|
|
success=True,
|
|
message="Logged out successfully"
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error during logout for user {current_user.id}: {str(e)}", exc_info=True)
|
|
# Don't expose error details
|
|
return MessageResponse(
|
|
success=True,
|
|
message="Logged out successfully"
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/logout-all",
|
|
response_model=MessageResponse,
|
|
status_code=status.HTTP_200_OK,
|
|
summary="Logout from All Devices",
|
|
description="""
|
|
Logout from ALL devices.
|
|
|
|
This will terminate all active sessions for the current user.
|
|
You will need to log in again on all devices.
|
|
|
|
**Rate Limit**: 5 requests/minute
|
|
""",
|
|
operation_id="logout_all"
|
|
)
|
|
@limiter.limit("5/minute")
|
|
async def logout_all(
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> Any:
|
|
"""
|
|
Logout from all devices by deactivating all user sessions.
|
|
|
|
Args:
|
|
current_user: Current authenticated user
|
|
db: Database session
|
|
|
|
Returns:
|
|
Success message with count of sessions terminated
|
|
"""
|
|
try:
|
|
# Deactivate all sessions for this user
|
|
count = await session_crud.deactivate_all_user_sessions(db, user_id=str(current_user.id))
|
|
|
|
logger.info(f"User {current_user.id} logged out from all devices ({count} sessions)")
|
|
|
|
return MessageResponse(
|
|
success=True,
|
|
message=f"Successfully logged out from all devices ({count} sessions terminated)"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error during logout-all for user {current_user.id}: {str(e)}", exc_info=True)
|
|
await db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="An error occurred while logging out"
|
|
)
|