Changes committed
This commit is contained in:
246
docengine/app/api/v1/auth.py
Normal file
246
docengine/app/api/v1/auth.py
Normal file
@@ -0,0 +1,246 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user