218 lines
6.4 KiB
Python
218 lines
6.4 KiB
Python
"""
|
|
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()
|
|
}
|