Switched from Dockmon to Beszel

This commit is contained in:
2025-10-31 17:13:00 +01:00
parent cc6454cef9
commit f4a4142799
75 changed files with 24313 additions and 122 deletions

View File

@@ -0,0 +1 @@
# Authentication module for DockMon

View File

@@ -0,0 +1,260 @@
"""
Authentication Routes for DockMon
Handles login, logout, API key access, and session management endpoints
"""
import os
import secrets
import logging
from typing import Optional
from fastapi import APIRouter, Request, Response, HTTPException, Depends
from fastapi.responses import JSONResponse
from models.auth_models import LoginRequest, ChangePasswordRequest
from security.rate_limiting import rate_limit_auth
from security.audit import security_audit
from auth.session_manager import session_manager
from database import DatabaseManager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["authentication"])
def _is_localhost_or_internal(client_ip: str) -> bool:
"""Check if request is from localhost or internal network"""
import ipaddress
try:
addr = ipaddress.ip_address(client_ip)
# Allow localhost
if addr.is_loopback:
return True
# Allow private networks (RFC 1918) - for Docker networks and internal deployments
if addr.is_private:
return True
return False
except ValueError:
# Invalid IP format
return False
from config.paths import DATABASE_PATH
# Initialize database for user management - use centralized path config
db = DatabaseManager(DATABASE_PATH)
# Ensure default user exists on startup
db.get_or_create_default_user()
def _get_session_from_cookie(request: Request) -> Optional[str]:
"""Extract session ID from cookie"""
return request.cookies.get("dockmon_session")
async def verify_frontend_session(request: Request) -> bool:
"""Dependency to verify frontend session authentication"""
session_id = _get_session_from_cookie(request)
if not session_manager.validate_session(session_id, request):
raise HTTPException(
status_code=401,
detail="Authentication required"
)
return True
# Note: This endpoint will be implemented in main.py since it needs monitor instance
# @router.get("/key") - implemented directly in main.py
@router.post("/login")
async def login(login_data: LoginRequest, request: Request, response: Response, rate_limit_check: bool = rate_limit_auth):
"""Frontend login endpoint"""
client_ip = request.client.host
user_agent = request.headers.get("user-agent", "Unknown")
# Verify credentials using database
user_info = db.verify_user_credentials(login_data.username, login_data.password)
if not user_info:
security_audit.log_login_failure(client_ip, user_agent, "Invalid credentials")
raise HTTPException(
status_code=401,
detail="Invalid username or password"
)
# Create session with username
session_id = session_manager.create_session(request, user_info["username"])
# Set secure cookie
# Detect if we're using HTTPS based on common headers
is_https = request.url.scheme == "https" or request.headers.get("x-forwarded-proto") == "https"
response.set_cookie(
key="dockmon_session",
value=session_id,
httponly=True, # Prevent XSS access to cookie
secure=is_https, # Use secure flag when on HTTPS
samesite="lax", # CSRF protection
max_age=24*60*60 # 24 hours
)
return {
"success": True,
"message": "Login successful",
"username": user_info["username"],
"must_change_password": user_info["must_change_password"],
"is_first_login": user_info["is_first_login"]
}
@router.post("/logout")
async def logout(request: Request, response: Response, authenticated: bool = Depends(verify_frontend_session)):
"""Frontend logout endpoint"""
session_id = _get_session_from_cookie(request)
if session_id:
session_manager.delete_session(session_id)
# Clear cookie
response.delete_cookie("dockmon_session")
return {"success": True, "message": "Logout successful"}
@router.get("/status")
async def auth_status(request: Request):
"""Check authentication status"""
session_id = _get_session_from_cookie(request)
authenticated = session_manager.validate_session(session_id, request) if session_id else False
response = {
"authenticated": authenticated,
"session_valid": authenticated
}
# If authenticated, include username and password change requirement
if authenticated:
username = session_manager.get_session_username(session_id)
if username:
response["username"] = username
# Check if user must change password
with db.get_session() as session:
from database import User
user = session.query(User).filter(User.username == username).first()
if user:
response["must_change_password"] = user.must_change_password
response["is_first_login"] = user.is_first_login
return response
@router.post("/change-password")
async def change_password(password_data: ChangePasswordRequest, request: Request, authenticated: bool = Depends(verify_frontend_session)):
"""Change user password"""
session_id = _get_session_from_cookie(request)
username = session_manager.get_session_username(session_id)
if not username:
raise HTTPException(
status_code=401,
detail="Session invalid"
)
# Verify current password
user_info = db.verify_user_credentials(username, password_data.current_password)
if not user_info:
raise HTTPException(
status_code=401,
detail="Current password is incorrect"
)
# Change password
success = db.change_user_password(username, password_data.new_password)
if not success:
raise HTTPException(
status_code=500,
detail="Failed to change password"
)
# Log security event
client_ip = request.client.host
user_agent = request.headers.get("user-agent", "Unknown")
security_audit.log_password_change(client_ip, user_agent, username)
return {
"success": True,
"message": "Password changed successfully"
}
@router.post("/change-username")
async def change_username(username_data: dict, request: Request, authenticated: bool = Depends(verify_frontend_session)):
"""Change username"""
session_id = _get_session_from_cookie(request)
current_username = session_manager.get_session_username(session_id)
if not current_username:
raise HTTPException(
status_code=401,
detail="Session invalid"
)
# Verify current password
current_password = username_data.get("current_password")
new_username = username_data.get("new_username", "").strip()
if not current_password or not new_username:
raise HTTPException(
status_code=400,
detail="Current password and new username required"
)
# Verify current credentials
user_info = db.verify_user_credentials(current_username, current_password)
if not user_info:
raise HTTPException(
status_code=401,
detail="Current password is incorrect"
)
# Validate new username
if len(new_username) < 3 or len(new_username) > 50:
raise HTTPException(
status_code=400,
detail="Username must be between 3 and 50 characters"
)
# Check if new username already exists (but allow keeping the same username)
if new_username != current_username and db.username_exists(new_username):
raise HTTPException(
status_code=400,
detail="Username already exists"
)
# Change username
if not db.change_username(current_username, new_username):
raise HTTPException(
status_code=500,
detail="Failed to change username"
)
# Update session with new username
session_manager.update_session_username(session_id, new_username)
# Log security event
client_ip = request.client.host
user_agent = request.headers.get("user-agent", "Unknown")
security_audit.log_username_change(client_ip, user_agent, current_username, new_username)
return {
"success": True,
"message": "Username changed successfully"
}

View File

@@ -0,0 +1,138 @@
"""
Session Management System for DockMon
Provides secure session tokens with IP validation and automatic cleanup
"""
import logging
import secrets
import threading
import time
from datetime import datetime, timedelta
from typing import Dict, Optional
from fastapi import Request
from security.audit import security_audit
logger = logging.getLogger(__name__)
class SessionManager:
"""
Custom session management for frontend authentication
Provides secure session tokens with configurable expiry
"""
def __init__(self):
self.sessions: Dict[str, dict] = {}
self.session_timeout = timedelta(hours=24) # 24 hour sessions
self._sessions_lock = threading.Lock()
self._shutdown_event = threading.Event()
self._cleanup_thread = threading.Thread(target=self._periodic_cleanup, daemon=True)
self._cleanup_thread.start()
def _periodic_cleanup(self):
"""Run cleanup every hour"""
while not self._shutdown_event.wait(timeout=3600):
try:
deleted = self.cleanup_expired_sessions()
if deleted > 0:
logger.info(f"Cleaned up {deleted} expired sessions")
except Exception as e:
logger.error(f"Session cleanup failed: {e}", exc_info=True)
def create_session(self, request: Request, username: str = None) -> str:
"""Create a new session token"""
session_id = secrets.token_urlsafe(32)
client_ip = request.client.host
user_agent = request.headers.get("user-agent", "Unknown")
with self._sessions_lock:
self.sessions[session_id] = {
"created_at": datetime.utcnow(),
"last_accessed": datetime.utcnow(),
"client_ip": client_ip,
"user_agent": user_agent,
"authenticated": True,
"username": username
}
# Security audit log
security_audit.log_login_success(client_ip, user_agent, session_id)
return session_id
def validate_session(self, session_id: Optional[str], request: Request) -> bool:
"""Validate session token and update last accessed time"""
if not session_id:
return False
with self._sessions_lock:
if session_id not in self.sessions:
return False
session = self.sessions[session_id]
current_time = datetime.utcnow()
client_ip = request.client.host
# Check if session has expired
if current_time - session["created_at"] > self.session_timeout:
del self.sessions[session_id]
security_audit.log_session_expired(client_ip, session_id)
return False
# Validate IP consistency for security
if session["client_ip"] != client_ip:
security_audit.log_session_hijack_attempt(
original_ip=session["client_ip"],
attempted_ip=client_ip,
session_id=session_id
)
del self.sessions[session_id]
return False
# Update last accessed time
session["last_accessed"] = current_time
return True
def delete_session(self, session_id: str):
"""Delete a session (logout)"""
with self._sessions_lock:
if session_id in self.sessions:
del self.sessions[session_id]
def get_session_username(self, session_id: str) -> Optional[str]:
"""Get username from session"""
with self._sessions_lock:
if session_id in self.sessions:
return self.sessions[session_id].get("username")
return None
def update_session_username(self, session_id: str, new_username: str):
"""Update username in session"""
with self._sessions_lock:
if session_id in self.sessions:
self.sessions[session_id]["username"] = new_username
def cleanup_expired_sessions(self):
"""Clean up expired sessions periodically"""
current_time = datetime.utcnow()
expired_sessions = []
with self._sessions_lock:
for session_id, session_data in self.sessions.items():
if current_time - session_data["created_at"] > self.session_timeout:
expired_sessions.append(session_id)
for session_id in expired_sessions:
self.delete_session(session_id)
return len(expired_sessions)
def shutdown(self):
"""Shutdown the session manager and cleanup thread"""
self._shutdown_event.set()
self._cleanup_thread.join(timeout=5)
# Global session manager instance
session_manager = SessionManager()