247 lines
7.2 KiB
Python
247 lines
7.2 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.config import settings
|
|
from app.core.database import get_db
|
|
from app.core.dependencies import CurrentUser
|
|
from app.core.security import (
|
|
create_access_token,
|
|
create_refresh_token,
|
|
decode_token,
|
|
hash_password,
|
|
verify_password,
|
|
InvalidTokenError,
|
|
)
|
|
from app.repositories.user_repository import RefreshTokenRepository, UserRepository
|
|
from app.schemas.auth import (
|
|
ChangePasswordRequest,
|
|
LoginRequest,
|
|
RefreshTokenRequest,
|
|
RegisterRequest,
|
|
TokenResponse,
|
|
)
|
|
from app.schemas.common import SuccessResponse
|
|
from app.schemas.user import UserResponse
|
|
|
|
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
|
|
|
|
|
@router.post(
|
|
"/register",
|
|
response_model=UserResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Register User",
|
|
description="Register a new user account.",
|
|
)
|
|
def register(
|
|
payload: RegisterRequest,
|
|
db: Session = Depends(get_db),
|
|
) -> UserResponse:
|
|
"""Register a new user."""
|
|
user_repo = UserRepository(db)
|
|
|
|
# Check for existing user
|
|
if user_repo.get_by_username(payload.username):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Username '{payload.username}' is already taken",
|
|
)
|
|
if user_repo.get_by_email(payload.email):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Email '{payload.email}' is already registered",
|
|
)
|
|
|
|
hashed = hash_password(payload.password)
|
|
user = user_repo.create_user(
|
|
username=payload.username,
|
|
email=payload.email,
|
|
hashed_password=hashed,
|
|
full_name=payload.full_name,
|
|
role_names=["user"],
|
|
)
|
|
db.commit()
|
|
db.refresh(user)
|
|
|
|
return UserResponse.model_validate(user)
|
|
|
|
|
|
@router.post(
|
|
"/login",
|
|
response_model=TokenResponse,
|
|
summary="Login",
|
|
description="Authenticate with username and password to obtain JWT tokens.",
|
|
)
|
|
def login(
|
|
payload: LoginRequest,
|
|
db: Session = Depends(get_db),
|
|
) -> TokenResponse:
|
|
"""Authenticate user and return JWT tokens."""
|
|
user_repo = UserRepository(db)
|
|
refresh_repo = RefreshTokenRepository(db)
|
|
|
|
user = user_repo.get_by_username(payload.username)
|
|
if not user or not verify_password(payload.password, user.hashed_password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid username or password",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
if not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="User account is deactivated",
|
|
)
|
|
|
|
# Generate tokens
|
|
access_token = create_access_token(data={"sub": str(user.id), "username": user.username})
|
|
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
|
|
|
# Store refresh token
|
|
expires_at = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_expire_days)
|
|
refresh_repo.create_token(
|
|
user_id=user.id,
|
|
token=refresh_token_str,
|
|
expires_at=expires_at,
|
|
)
|
|
|
|
# Update last login
|
|
user_repo.update_last_login(user)
|
|
db.commit()
|
|
|
|
return TokenResponse(
|
|
access_token=access_token,
|
|
refresh_token=refresh_token_str,
|
|
token_type="bearer",
|
|
expires_in=settings.jwt_access_token_expire_minutes * 60,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/refresh",
|
|
response_model=TokenResponse,
|
|
summary="Refresh Token",
|
|
description="Obtain a new access token using a valid refresh token.",
|
|
)
|
|
def refresh_token(
|
|
payload: RefreshTokenRequest,
|
|
db: Session = Depends(get_db),
|
|
) -> TokenResponse:
|
|
"""Refresh access token using a refresh token."""
|
|
refresh_repo = RefreshTokenRepository(db)
|
|
user_repo = UserRepository(db)
|
|
|
|
# Validate the refresh token
|
|
try:
|
|
token_payload = decode_token(payload.refresh_token)
|
|
except InvalidTokenError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid or expired refresh token",
|
|
)
|
|
|
|
if token_payload.get("type") != "refresh":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid token type",
|
|
)
|
|
|
|
# Check if token exists in database and is not revoked
|
|
stored_token = refresh_repo.get_by_token(payload.refresh_token)
|
|
if not stored_token:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Refresh token not found or revoked",
|
|
)
|
|
|
|
user = user_repo.get_by_id(token_payload["sub"])
|
|
if not user or not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User not found or deactivated",
|
|
)
|
|
|
|
# Revoke old refresh token
|
|
refresh_repo.revoke_token(payload.refresh_token)
|
|
|
|
# Generate new tokens
|
|
new_access_token = create_access_token(data={"sub": str(user.id), "username": user.username})
|
|
new_refresh_token = create_refresh_token(data={"sub": str(user.id)})
|
|
|
|
expires_at = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_expire_days)
|
|
refresh_repo.create_token(
|
|
user_id=user.id,
|
|
token=new_refresh_token,
|
|
expires_at=expires_at,
|
|
)
|
|
db.commit()
|
|
|
|
return TokenResponse(
|
|
access_token=new_access_token,
|
|
refresh_token=new_refresh_token,
|
|
token_type="bearer",
|
|
expires_in=settings.jwt_access_token_expire_minutes * 60,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/logout",
|
|
response_model=SuccessResponse,
|
|
summary="Logout",
|
|
description="Revoke the current refresh token.",
|
|
)
|
|
def logout(
|
|
payload: RefreshTokenRequest,
|
|
current_user: CurrentUser,
|
|
db: Session = Depends(get_db),
|
|
) -> SuccessResponse:
|
|
"""Logout by revoking the refresh token."""
|
|
refresh_repo = RefreshTokenRepository(db)
|
|
refresh_repo.revoke_token(payload.refresh_token)
|
|
db.commit()
|
|
return SuccessResponse(message="Successfully logged out")
|
|
|
|
|
|
@router.post(
|
|
"/change-password",
|
|
response_model=SuccessResponse,
|
|
summary="Change Password",
|
|
description="Change the current user's password.",
|
|
)
|
|
def change_password(
|
|
payload: ChangePasswordRequest,
|
|
current_user: CurrentUser,
|
|
db: Session = Depends(get_db),
|
|
) -> SuccessResponse:
|
|
"""Change user password."""
|
|
if not verify_password(payload.current_password, current_user.hashed_password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Current password is incorrect",
|
|
)
|
|
|
|
current_user.hashed_password = hash_password(payload.new_password)
|
|
|
|
# Revoke all refresh tokens for security
|
|
refresh_repo = RefreshTokenRepository(db)
|
|
refresh_repo.revoke_all_user_tokens(current_user.id)
|
|
db.commit()
|
|
|
|
return SuccessResponse(message="Password changed successfully")
|
|
|
|
|
|
@router.get(
|
|
"/me",
|
|
response_model=UserResponse,
|
|
summary="Get Current User",
|
|
description="Get the currently authenticated user's profile.",
|
|
)
|
|
def get_me(current_user: CurrentUser) -> UserResponse:
|
|
"""Get current authenticated user profile."""
|
|
return UserResponse.model_validate(current_user)
|