Files
pragma-stack/backend/app/api/routes/auth.py
Felipe Cardoso 182b12b2d5 Add password reset functionality, email service, and related API endpoints
- Introduced endpoints for requesting and confirming password resets.
- Implemented token-based password reset logic with validation checks.
- Added `EmailService` with `ConsoleEmailBackend` and placeholder for SMTP backend.
- Integrated password reset flow in `auth` API routes with rate limiting.
- Updated schemas for password reset requests and token confirmation.
- Refined validation for secure password updates and token verification.
- Enhanced configuration with `FRONTEND_URL` for email links.
2025-10-30 16:54:18 +01:00

350 lines
11 KiB
Python

# app/api/routes/auth.py
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status, Body, Request
from fastapi.security import OAuth2PasswordRequestForm
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy.orm import Session
from app.api.dependencies.auth import get_current_user
from app.core.auth import TokenExpiredError, TokenInvalidError
from app.core.database import get_db
from app.models.user import User
from app.schemas.users import (
UserCreate,
UserResponse,
Token,
LoginRequest,
RefreshTokenRequest,
PasswordResetRequest,
PasswordResetConfirm
)
from app.schemas.common import MessageResponse
from app.services.auth_service import AuthService, AuthenticationError
from app.services.email_service import email_service
from app.utils.security import create_password_reset_token, verify_password_reset_token
from app.crud.user import user as user_crud
from app.core.auth import get_password_hash
router = APIRouter()
logger = logging.getLogger(__name__)
# Initialize limiter for this router
limiter = Limiter(key_func=get_remote_address)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED, operation_id="register")
@limiter.limit("5/minute")
async def register_user(
request: Request,
user_data: UserCreate,
db: Session = Depends(get_db)
) -> Any:
"""
Register a new user.
Returns:
The created user information.
"""
try:
user = AuthService.create_user(db, user_data)
return user
except AuthenticationError as e:
logger.warning(f"Registration failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(e)
)
except Exception as e:
logger.error(f"Unexpected error during registration: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred. Please try again later."
)
@router.post("/login", response_model=Token, operation_id="login")
@limiter.limit("10/minute")
async def login(
request: Request,
login_data: LoginRequest,
db: Session = Depends(get_db)
) -> Any:
"""
Login with username and password.
Returns:
Access and refresh tokens.
"""
try:
# Attempt to authenticate the user
user = 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 HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# User is authenticated, generate tokens
tokens = AuthService.create_tokens(user)
logger.info(f"User login successful: {user.email}")
return tokens
except HTTPException:
# Re-raise HTTP exceptions without modification
raise
except AuthenticationError as e:
# Handle specific authentication errors like inactive accounts
logger.warning(f"Authentication failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
headers={"WWW-Authenticate": "Bearer"},
)
except Exception as e:
# Handle unexpected errors
logger.error(f"Unexpected error during login: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred. Please try again later."
)
@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: Session = Depends(get_db)
) -> Any:
"""
OAuth2-compatible login endpoint, used by the OpenAPI UI.
Returns:
Access and refresh tokens.
"""
try:
user = AuthService.authenticate_user(db, form_data.username, form_data.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Generate tokens
tokens = AuthService.create_tokens(user)
# Format response for OAuth compatibility
return {
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"token_type": tokens.token_type
}
except HTTPException:
raise
except AuthenticationError as e:
logger.warning(f"OAuth authentication failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
headers={"WWW-Authenticate": "Bearer"},
)
except Exception as e:
logger.error(f"Unexpected error during OAuth login: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred. Please try again later."
)
@router.post("/refresh", response_model=Token, operation_id="refresh_token")
@limiter.limit("30/minute")
async def refresh_token(
request: Request,
refresh_data: RefreshTokenRequest,
db: Session = Depends(get_db)
) -> Any:
"""
Refresh access token using a refresh token.
Returns:
New access and refresh tokens.
"""
try:
tokens = AuthService.refresh_tokens(db, refresh_data.refresh_token)
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 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.get("/me", response_model=UserResponse, operation_id="get_current_user_info")
@limiter.limit("60/minute")
async def get_current_user_info(
request: Request,
current_user: User = Depends(get_current_user)
) -> Any:
"""
Get current user information.
Requires authentication.
"""
return current_user
@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: Session = 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 = 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")
def confirm_password_reset(
request: Request,
reset_confirm: PasswordResetConfirm,
db: Session = 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 = 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)
db.commit()
logger.info(f"Password reset successful for {user.email}")
return MessageResponse(
success=True,
message="Password has been reset successfully. 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)
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An error occurred while resetting your password"
)