459 lines
12 KiB
Python
459 lines
12 KiB
Python
"""Authentication API endpoints."""
|
|
|
|
from typing import Optional
|
|
from datetime import datetime, timedelta
|
|
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
|
from fastapi.security import OAuth2PasswordRequestForm
|
|
from pydantic import BaseModel, EmailStr, Field
|
|
from sqlalchemy.orm import Session
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
from core.database import get_db
|
|
from core.config import settings, auth_settings
|
|
from models.user import User
|
|
from services.auth_service import AuthService
|
|
from services.email_service import EmailService
|
|
from api.dependencies import get_current_user, get_current_active_user
|
|
|
|
|
|
router = APIRouter(prefix="/api/auth", tags=["authentication"])
|
|
|
|
|
|
# Request/Response models
|
|
class UserRegisterRequest(BaseModel):
|
|
"""User registration request model."""
|
|
email: EmailStr
|
|
password: str = Field(..., min_length=8)
|
|
confirm_password: str
|
|
|
|
def validate_passwords(self) -> tuple[bool, str]:
|
|
"""Validate password requirements."""
|
|
if self.password != self.confirm_password:
|
|
return False, "Passwords do not match"
|
|
|
|
return auth_settings.validate_password_requirements(self.password)
|
|
|
|
|
|
class UserLoginRequest(BaseModel):
|
|
"""User login request model."""
|
|
email: EmailStr
|
|
password: str
|
|
|
|
|
|
class TokenResponse(BaseModel):
|
|
"""Token response model."""
|
|
access_token: str
|
|
refresh_token: str
|
|
token_type: str = "bearer"
|
|
expires_in: int # seconds
|
|
|
|
|
|
class UserResponse(BaseModel):
|
|
"""User response model."""
|
|
id: str
|
|
email: str
|
|
is_verified: bool
|
|
is_active: bool
|
|
created_at: datetime
|
|
last_login: Optional[datetime]
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class MessageResponse(BaseModel):
|
|
"""Simple message response."""
|
|
message: str
|
|
success: bool = True
|
|
|
|
|
|
class RefreshTokenRequest(BaseModel):
|
|
"""Refresh token request model."""
|
|
refresh_token: str
|
|
|
|
|
|
class PasswordResetRequest(BaseModel):
|
|
"""Password reset request model."""
|
|
email: EmailStr
|
|
|
|
|
|
class PasswordResetConfirmRequest(BaseModel):
|
|
"""Password reset confirmation model."""
|
|
token: str
|
|
new_password: str = Field(..., min_length=8)
|
|
confirm_password: str
|
|
|
|
|
|
# Endpoints
|
|
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
|
async def register(
|
|
request: UserRegisterRequest,
|
|
background_tasks: BackgroundTasks,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Register a new user account.
|
|
|
|
Args:
|
|
request: Registration details
|
|
background_tasks: Background task runner
|
|
db: Database session
|
|
|
|
Returns:
|
|
Created user
|
|
|
|
Raises:
|
|
HTTPException: If registration fails
|
|
"""
|
|
# Validate passwords
|
|
valid, message = request.validate_passwords()
|
|
if not valid:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=message
|
|
)
|
|
|
|
# Check if user exists
|
|
existing_user = db.query(User).filter(User.email == request.email).first()
|
|
if existing_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Email already registered"
|
|
)
|
|
|
|
# Create user
|
|
hashed_password = AuthService.hash_password(request.password)
|
|
user = User(
|
|
email=request.email,
|
|
password_hash=hashed_password,
|
|
is_verified=False,
|
|
is_active=True
|
|
)
|
|
|
|
db.add(user)
|
|
db.commit()
|
|
db.refresh(user)
|
|
|
|
# Send verification email in background
|
|
verification_token = AuthService.create_email_verification_token(str(user.id))
|
|
background_tasks.add_task(
|
|
EmailService.send_verification_email,
|
|
email=user.email,
|
|
token=verification_token
|
|
)
|
|
|
|
return UserResponse.from_orm(user)
|
|
|
|
|
|
@router.post("/login", response_model=TokenResponse)
|
|
async def login(
|
|
request: UserLoginRequest,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Login with email and password.
|
|
|
|
Args:
|
|
request: Login credentials
|
|
db: Database session
|
|
|
|
Returns:
|
|
Access and refresh tokens
|
|
|
|
Raises:
|
|
HTTPException: If authentication fails
|
|
"""
|
|
user = AuthService.authenticate_user(request.email, request.password, db)
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid email or password",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
if not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Account is disabled"
|
|
)
|
|
|
|
# Create tokens
|
|
access_token = AuthService.create_access_token(
|
|
data={"sub": str(user.id), "email": user.email}
|
|
)
|
|
refresh_token = AuthService.create_refresh_token(str(user.id), db)
|
|
|
|
return TokenResponse(
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
|
)
|
|
|
|
|
|
@router.post("/refresh", response_model=TokenResponse)
|
|
async def refresh_token(
|
|
request: RefreshTokenRequest,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Refresh access token using refresh token.
|
|
|
|
Args:
|
|
request: Refresh token
|
|
db: Database session
|
|
|
|
Returns:
|
|
New access and refresh tokens
|
|
|
|
Raises:
|
|
HTTPException: If refresh token is invalid
|
|
"""
|
|
# Verify refresh token
|
|
token_obj = AuthService.verify_refresh_token(request.refresh_token, db)
|
|
|
|
if not token_obj:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid refresh token"
|
|
)
|
|
|
|
# Get user
|
|
user = db.query(User).filter(User.id == token_obj.user_id).first()
|
|
|
|
if not user or not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User not found or inactive"
|
|
)
|
|
|
|
# Revoke old refresh token
|
|
AuthService.revoke_refresh_token(request.refresh_token, db)
|
|
|
|
# Create new tokens
|
|
access_token = AuthService.create_access_token(
|
|
data={"sub": str(user.id), "email": user.email}
|
|
)
|
|
new_refresh_token = AuthService.create_refresh_token(str(user.id), db)
|
|
|
|
return TokenResponse(
|
|
access_token=access_token,
|
|
refresh_token=new_refresh_token,
|
|
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
|
)
|
|
|
|
|
|
@router.post("/logout", response_model=MessageResponse)
|
|
async def logout(
|
|
refresh_token: Optional[str] = None,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Logout user and revoke tokens.
|
|
|
|
Args:
|
|
refresh_token: Optional refresh token to revoke
|
|
current_user: Current authenticated user
|
|
db: Database session
|
|
|
|
Returns:
|
|
Success message
|
|
"""
|
|
if refresh_token:
|
|
AuthService.revoke_refresh_token(refresh_token, db)
|
|
else:
|
|
# Revoke all user tokens
|
|
AuthService.revoke_all_user_tokens(str(current_user.id), db)
|
|
|
|
return MessageResponse(message="Logged out successfully")
|
|
|
|
|
|
@router.get("/me", response_model=UserResponse)
|
|
async def get_current_user_info(
|
|
current_user: User = Depends(get_current_active_user)
|
|
):
|
|
"""
|
|
Get current user information.
|
|
|
|
Args:
|
|
current_user: Current authenticated user
|
|
|
|
Returns:
|
|
User information
|
|
"""
|
|
return UserResponse.from_orm(current_user)
|
|
|
|
|
|
@router.post("/verify-email", response_model=MessageResponse)
|
|
async def verify_email(
|
|
token: str,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Verify email address with token.
|
|
|
|
Args:
|
|
token: Email verification token
|
|
db: Database session
|
|
|
|
Returns:
|
|
Success message
|
|
|
|
Raises:
|
|
HTTPException: If verification fails
|
|
"""
|
|
user_id = AuthService.verify_email_token(token)
|
|
|
|
if not user_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid or expired verification token"
|
|
)
|
|
|
|
# Update user
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
if user.is_verified:
|
|
return MessageResponse(message="Email already verified")
|
|
|
|
user.is_verified = True
|
|
db.commit()
|
|
|
|
return MessageResponse(message="Email verified successfully")
|
|
|
|
|
|
@router.post("/resend-verification", response_model=MessageResponse)
|
|
async def resend_verification(
|
|
background_tasks: BackgroundTasks,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Resend email verification link.
|
|
|
|
Args:
|
|
background_tasks: Background task runner
|
|
current_user: Current authenticated user
|
|
db: Database session
|
|
|
|
Returns:
|
|
Success message
|
|
"""
|
|
if current_user.is_verified:
|
|
return MessageResponse(message="Email already verified")
|
|
|
|
# Send new verification email
|
|
verification_token = AuthService.create_email_verification_token(str(current_user.id))
|
|
background_tasks.add_task(
|
|
EmailService.send_verification_email,
|
|
email=current_user.email,
|
|
token=verification_token
|
|
)
|
|
|
|
return MessageResponse(message="Verification email sent")
|
|
|
|
|
|
@router.post("/reset-password", response_model=MessageResponse)
|
|
async def reset_password_request(
|
|
request: PasswordResetRequest,
|
|
background_tasks: BackgroundTasks,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Request password reset email.
|
|
|
|
Args:
|
|
request: Email for password reset
|
|
background_tasks: Background task runner
|
|
db: Database session
|
|
|
|
Returns:
|
|
Success message (always returns success for security)
|
|
"""
|
|
# Find user
|
|
user = db.query(User).filter(User.email == request.email).first()
|
|
|
|
if user:
|
|
# Send password reset email
|
|
reset_token = AuthService.create_password_reset_token(str(user.id))
|
|
background_tasks.add_task(
|
|
EmailService.send_password_reset_email,
|
|
email=user.email,
|
|
token=reset_token
|
|
)
|
|
|
|
# Always return success for security (don't reveal if email exists)
|
|
return MessageResponse(
|
|
message="If the email exists, a password reset link has been sent"
|
|
)
|
|
|
|
|
|
@router.post("/reset-password/confirm", response_model=MessageResponse)
|
|
async def reset_password_confirm(
|
|
request: PasswordResetConfirmRequest,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Confirm password reset with new password.
|
|
|
|
Args:
|
|
request: Reset token and new password
|
|
db: Database session
|
|
|
|
Returns:
|
|
Success message
|
|
|
|
Raises:
|
|
HTTPException: If reset fails
|
|
"""
|
|
# Validate passwords match
|
|
if request.new_password != request.confirm_password:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Passwords do not match"
|
|
)
|
|
|
|
# Validate password requirements
|
|
valid, message = auth_settings.validate_password_requirements(request.new_password)
|
|
if not valid:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=message
|
|
)
|
|
|
|
# Verify token
|
|
user_id = AuthService.verify_password_reset_token(request.token)
|
|
|
|
if not user_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid or expired reset token"
|
|
)
|
|
|
|
# Update password
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
user.password_hash = AuthService.hash_password(request.new_password)
|
|
|
|
# Revoke all refresh tokens for security
|
|
AuthService.revoke_all_user_tokens(str(user.id), db)
|
|
|
|
db.commit()
|
|
|
|
return MessageResponse(message="Password reset successfully") |