217
web/backend/routers/auth.py
Normal file
217
web/backend/routers/auth.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Authentication Router
|
||||
|
||||
Handles all authentication-related endpoints:
|
||||
- Login/Logout
|
||||
- User info
|
||||
- Password changes
|
||||
- User preferences
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from typing import Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from ..core.dependencies import get_current_user, get_app_state
|
||||
from ..core.config import settings
|
||||
from ..core.exceptions import AuthError, handle_exceptions
|
||||
from ..core.responses import to_iso8601, now_iso8601
|
||||
from ..models.api_models import LoginRequest, ChangePasswordRequest, UserPreferences
|
||||
from modules.universal_logger import get_logger
|
||||
|
||||
logger = get_logger('API')
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
||||
|
||||
# Rate limiter - will be set from main app
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
@limiter.limit("5/minute")
|
||||
@handle_exceptions
|
||||
async def login(login_data: LoginRequest, request: Request):
|
||||
"""
|
||||
Authenticate user with username and password.
|
||||
|
||||
Returns JWT token or 2FA challenge if 2FA is enabled.
|
||||
"""
|
||||
app_state = get_app_state()
|
||||
|
||||
if not app_state.auth:
|
||||
raise HTTPException(status_code=500, detail="Authentication not initialized")
|
||||
|
||||
# Query user from database
|
||||
with sqlite3.connect(app_state.auth.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT password_hash, role, is_active, totp_enabled, duo_enabled, passkey_enabled
|
||||
FROM users WHERE username = ?
|
||||
""", (login_data.username,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
password_hash, role, is_active, totp_enabled, duo_enabled, passkey_enabled = row
|
||||
|
||||
if not is_active:
|
||||
raise HTTPException(status_code=401, detail="Account is inactive")
|
||||
|
||||
if not app_state.auth.verify_password(login_data.password, password_hash):
|
||||
app_state.auth._record_login_attempt(login_data.username, False)
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
# Check if user has any 2FA methods enabled
|
||||
available_methods = []
|
||||
if totp_enabled:
|
||||
available_methods.append('totp')
|
||||
if passkey_enabled:
|
||||
available_methods.append('passkey')
|
||||
if duo_enabled:
|
||||
available_methods.append('duo')
|
||||
|
||||
# If user has 2FA enabled, return require2FA flag
|
||||
if available_methods:
|
||||
return {
|
||||
'success': True,
|
||||
'require2FA': True,
|
||||
'availableMethods': available_methods,
|
||||
'username': login_data.username
|
||||
}
|
||||
|
||||
# No 2FA - proceed with normal login
|
||||
result = app_state.auth._create_session(
|
||||
username=login_data.username,
|
||||
role=role,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
remember_me=login_data.rememberMe
|
||||
)
|
||||
|
||||
# Create response with cookie
|
||||
response = JSONResponse(content=result)
|
||||
|
||||
# Set auth cookie (secure, httponly for security)
|
||||
max_age = 30 * 24 * 60 * 60 if login_data.rememberMe else None
|
||||
response.set_cookie(
|
||||
key="auth_token",
|
||||
value=result.get('token'),
|
||||
max_age=max_age,
|
||||
httponly=True,
|
||||
secure=settings.SECURE_COOKIES,
|
||||
samesite="lax",
|
||||
path="/"
|
||||
)
|
||||
|
||||
logger.info(f"User {login_data.username} logged in successfully", module="Auth")
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
@limiter.limit("10/minute")
|
||||
@handle_exceptions
|
||||
async def logout(request: Request, current_user: Dict = Depends(get_current_user)):
|
||||
"""Logout and invalidate session"""
|
||||
username = current_user.get('sub', 'unknown')
|
||||
|
||||
response = JSONResponse(content={
|
||||
"success": True,
|
||||
"message": "Logged out successfully",
|
||||
"timestamp": now_iso8601()
|
||||
})
|
||||
|
||||
# Clear auth cookie
|
||||
response.set_cookie(
|
||||
key="auth_token",
|
||||
value="",
|
||||
max_age=0,
|
||||
httponly=True,
|
||||
secure=settings.SECURE_COOKIES,
|
||||
samesite="lax",
|
||||
path="/"
|
||||
)
|
||||
|
||||
logger.info(f"User {username} logged out", module="Auth")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
@limiter.limit("30/minute")
|
||||
@handle_exceptions
|
||||
async def get_me(request: Request, current_user: Dict = Depends(get_current_user)):
|
||||
"""Get current user information"""
|
||||
app_state = get_app_state()
|
||||
username = current_user.get('sub')
|
||||
|
||||
user_info = app_state.auth.get_user(username)
|
||||
if not user_info:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return user_info
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
@limiter.limit("5/minute")
|
||||
@handle_exceptions
|
||||
async def change_password(
|
||||
request: Request,
|
||||
current_password: str = Body(..., embed=True),
|
||||
new_password: str = Body(..., embed=True),
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Change user password"""
|
||||
app_state = get_app_state()
|
||||
username = current_user.get('sub')
|
||||
ip_address = request.client.host if request.client else None
|
||||
|
||||
# Validate new password
|
||||
if len(new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
|
||||
result = app_state.auth.change_password(username, current_password, new_password, ip_address)
|
||||
|
||||
if not result['success']:
|
||||
raise HTTPException(status_code=400, detail=result.get('error', 'Password change failed'))
|
||||
|
||||
logger.info(f"Password changed for user {username}", module="Auth")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Password changed successfully",
|
||||
"timestamp": now_iso8601()
|
||||
}
|
||||
|
||||
|
||||
@router.post("/preferences")
|
||||
@limiter.limit("10/minute")
|
||||
@handle_exceptions
|
||||
async def update_preferences(
|
||||
request: Request,
|
||||
preferences: dict = Body(...),
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Update user preferences (theme, notifications, etc.)"""
|
||||
app_state = get_app_state()
|
||||
username = current_user.get('sub')
|
||||
|
||||
# Validate theme if provided
|
||||
if 'theme' in preferences:
|
||||
if preferences['theme'] not in ('light', 'dark', 'system'):
|
||||
raise HTTPException(status_code=400, detail="Invalid theme value")
|
||||
|
||||
result = app_state.auth.update_preferences(username, preferences)
|
||||
|
||||
if not result['success']:
|
||||
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to update preferences'))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Preferences updated",
|
||||
"preferences": preferences,
|
||||
"timestamp": now_iso8601()
|
||||
}
|
||||
Reference in New Issue
Block a user