Switched from Dockmon to Beszel
This commit is contained in:
1
dockmon/backend/auth/__init__.py
Normal file
1
dockmon/backend/auth/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Authentication module for DockMon
|
||||
260
dockmon/backend/auth/routes.py
Normal file
260
dockmon/backend/auth/routes.py
Normal 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"
|
||||
}
|
||||
138
dockmon/backend/auth/session_manager.py
Normal file
138
dockmon/backend/auth/session_manager.py
Normal 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()
|
||||
Reference in New Issue
Block a user