Initial commit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Todd
2026-03-29 22:42:55 -04:00
commit 0d7b2b1aab
389 changed files with 280296 additions and 0 deletions

217
web/backend/routers/auth.py Normal file
View 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()
}