manage-your-printer/utils/form_validation.py
2025-06-04 10:03:22 +02:00

663 lines
26 KiB
Python

"""
Erweiterte Formular-Validierung für das MYP-System
==================================================
Dieses Modul stellt umfassende Client- und serverseitige Validierung
mit benutzerfreundlichem UI-Feedback bereit.
Funktionen:
- Multi-Level-Validierung (Client/Server)
- Echtzeitvalidierung mit JavaScript
- Barrierefreie Fehlermeldungen
- Custom Validators für spezielle Anforderungen
- Automatische Sanitization von Eingaben
"""
import re
import html
import json
import logging
from typing import Dict, List, Any, Optional, Callable, Union
from datetime import datetime, timedelta
from flask import request, jsonify, session
from functools import wraps
from werkzeug.datastructures import FileStorage
from utils.logging_config import get_logger
from config.settings import ALLOWED_EXTENSIONS, MAX_FILE_SIZE
logger = get_logger("validation")
class ValidationError(Exception):
"""Custom Exception für Validierungsfehler"""
def __init__(self, message: str, field: str = None, code: str = None):
self.message = message
self.field = field
self.code = code
super().__init__(self.message)
class ValidationResult:
"""Ergebnis einer Validierung"""
def __init__(self):
self.is_valid = True
self.errors: Dict[str, List[str]] = {}
self.warnings: Dict[str, List[str]] = {}
self.cleaned_data: Dict[str, Any] = {}
def add_error(self, field: str, message: str):
"""Fügt einen Validierungsfehler hinzu"""
if field not in self.errors:
self.errors[field] = []
self.errors[field].append(message)
self.is_valid = False
def add_warning(self, field: str, message: str):
"""Fügt eine Warnung hinzu"""
if field not in self.warnings:
self.warnings[field] = []
self.warnings[field].append(message)
def to_dict(self) -> Dict[str, Any]:
"""Konvertiert das Ergebnis zu einem Dictionary"""
return {
"is_valid": self.is_valid,
"errors": self.errors,
"warnings": self.warnings,
"cleaned_data": self.cleaned_data
}
class BaseValidator:
"""Basis-Klasse für alle Validatoren"""
def __init__(self, required: bool = False, allow_empty: bool = True):
self.required = required
self.allow_empty = allow_empty
def validate(self, value: Any, field_name: str = None) -> ValidationResult:
"""Führt die Validierung durch"""
result = ValidationResult()
# Prüfung auf erforderliche Felder
if self.required and (value is None or value == ""):
result.add_error(field_name or "field", "Dieses Feld ist erforderlich.")
return result
# Wenn Wert leer und erlaubt, keine weitere Validierung
if not value and self.allow_empty:
result.cleaned_data[field_name or "field"] = value
return result
return self._validate_value(value, field_name, result)
def _validate_value(self, value: Any, field_name: str, result: ValidationResult) -> ValidationResult:
"""Überschreibbar für spezifische Validierungslogik"""
result.cleaned_data[field_name or "field"] = value
return result
class StringValidator(BaseValidator):
"""Validator für String-Werte"""
def __init__(self, min_length: int = None, max_length: int = None,
pattern: str = None, trim: bool = True, **kwargs):
super().__init__(**kwargs)
self.min_length = min_length
self.max_length = max_length
self.pattern = re.compile(pattern) if pattern else None
self.trim = trim
def _validate_value(self, value: Any, field_name: str, result: ValidationResult) -> ValidationResult:
# String konvertieren und trimmen
str_value = str(value)
if self.trim:
str_value = str_value.strip()
# Längenprüfung
if self.min_length is not None and len(str_value) < self.min_length:
result.add_error(field_name, f"Mindestlänge: {self.min_length} Zeichen")
if self.max_length is not None and len(str_value) > self.max_length:
result.add_error(field_name, f"Maximallänge: {self.max_length} Zeichen")
# Pattern-Prüfung
if self.pattern and not self.pattern.match(str_value):
result.add_error(field_name, "Format ist ungültig")
# HTML-Sanitization
cleaned_value = html.escape(str_value)
result.cleaned_data[field_name] = cleaned_value
return result
class EmailValidator(StringValidator):
"""Validator für E-Mail-Adressen"""
EMAIL_PATTERN = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
def __init__(self, **kwargs):
super().__init__(pattern=self.EMAIL_PATTERN, **kwargs)
def _validate_value(self, value: Any, field_name: str, result: ValidationResult) -> ValidationResult:
result = super()._validate_value(value, field_name, result)
if result.is_valid:
# Normalisierung der E-Mail
email = str(value).lower().strip()
result.cleaned_data[field_name] = email
return result
class IntegerValidator(BaseValidator):
"""Validator für Integer-Werte"""
def __init__(self, min_value: int = None, max_value: int = None, **kwargs):
super().__init__(**kwargs)
self.min_value = min_value
self.max_value = max_value
def _validate_value(self, value: Any, field_name: str, result: ValidationResult) -> ValidationResult:
try:
int_value = int(value)
except (ValueError, TypeError):
result.add_error(field_name, "Muss eine ganze Zahl sein")
return result
if self.min_value is not None and int_value < self.min_value:
result.add_error(field_name, f"Mindestwert: {self.min_value}")
if self.max_value is not None and int_value > self.max_value:
result.add_error(field_name, f"Maximalwert: {self.max_value}")
result.cleaned_data[field_name] = int_value
return result
class FloatValidator(BaseValidator):
"""Validator für Float-Werte"""
def __init__(self, min_value: float = None, max_value: float = None,
decimal_places: int = None, **kwargs):
super().__init__(**kwargs)
self.min_value = min_value
self.max_value = max_value
self.decimal_places = decimal_places
def _validate_value(self, value: Any, field_name: str, result: ValidationResult) -> ValidationResult:
try:
float_value = float(value)
except (ValueError, TypeError):
result.add_error(field_name, "Muss eine Dezimalzahl sein")
return result
if self.min_value is not None and float_value < self.min_value:
result.add_error(field_name, f"Mindestwert: {self.min_value}")
if self.max_value is not None and float_value > self.max_value:
result.add_error(field_name, f"Maximalwert: {self.max_value}")
# Rundung auf bestimmte Dezimalstellen
if self.decimal_places is not None:
float_value = round(float_value, self.decimal_places)
result.cleaned_data[field_name] = float_value
return result
class DateTimeValidator(BaseValidator):
"""Validator für DateTime-Werte"""
def __init__(self, format_string: str = "%Y-%m-%d %H:%M",
min_date: datetime = None, max_date: datetime = None, **kwargs):
super().__init__(**kwargs)
self.format_string = format_string
self.min_date = min_date
self.max_date = max_date
def _validate_value(self, value: Any, field_name: str, result: ValidationResult) -> ValidationResult:
if isinstance(value, datetime):
dt_value = value
else:
try:
dt_value = datetime.strptime(str(value), self.format_string)
except ValueError:
result.add_error(field_name, f"Ungültiges Datumsformat. Erwartet: {self.format_string}")
return result
if self.min_date and dt_value < self.min_date:
result.add_error(field_name, f"Datum muss nach {self.min_date.strftime('%d.%m.%Y')} liegen")
if self.max_date and dt_value > self.max_date:
result.add_error(field_name, f"Datum muss vor {self.max_date.strftime('%d.%m.%Y')} liegen")
result.cleaned_data[field_name] = dt_value
return result
class FileValidator(BaseValidator):
"""Validator für Datei-Uploads"""
def __init__(self, allowed_extensions: List[str] = None,
max_size_mb: int = None, min_size_kb: int = None, **kwargs):
super().__init__(**kwargs)
self.allowed_extensions = allowed_extensions or ALLOWED_EXTENSIONS
self.max_size_mb = max_size_mb or (MAX_FILE_SIZE / (1024 * 1024))
self.min_size_kb = min_size_kb
def _validate_value(self, value: Any, field_name: str, result: ValidationResult) -> ValidationResult:
if not isinstance(value, FileStorage):
result.add_error(field_name, "Muss eine gültige Datei sein")
return result
# Dateiname prüfen
if not value.filename:
result.add_error(field_name, "Dateiname ist erforderlich")
return result
# Dateierweiterung prüfen
extension = value.filename.rsplit('.', 1)[-1].lower() if '.' in value.filename else ''
if extension not in self.allowed_extensions:
result.add_error(field_name,
f"Nur folgende Dateiformate sind erlaubt: {', '.join(self.allowed_extensions)}")
# Dateigröße prüfen
value.seek(0, 2) # Zum Ende der Datei
file_size = value.tell()
value.seek(0) # Zurück zum Anfang
if self.max_size_mb and file_size > (self.max_size_mb * 1024 * 1024):
result.add_error(field_name, f"Datei zu groß. Maximum: {self.max_size_mb} MB")
if self.min_size_kb and file_size < (self.min_size_kb * 1024):
result.add_error(field_name, f"Datei zu klein. Minimum: {self.min_size_kb} KB")
result.cleaned_data[field_name] = value
return result
class FormValidator:
"""Haupt-Formular-Validator"""
def __init__(self):
self.fields: Dict[str, BaseValidator] = {}
self.custom_validators: List[Callable] = []
self.rate_limit_key = None
self.csrf_check = True
def add_field(self, name: str, validator: BaseValidator):
"""Fügt ein Feld mit Validator hinzu"""
self.fields[name] = validator
return self
def add_custom_validator(self, validator_func: Callable):
"""Fügt einen benutzerdefinierten Validator hinzu"""
self.custom_validators.append(validator_func)
return self
def set_rate_limit(self, key: str):
"""Setzt einen Rate-Limiting-Schlüssel"""
self.rate_limit_key = key
return self
def disable_csrf(self):
"""Deaktiviert CSRF-Prüfung für dieses Formular"""
self.csrf_check = False
return self
def validate(self, data: Dict[str, Any]) -> ValidationResult:
"""Validiert die gesamten Formulardaten"""
result = ValidationResult()
# Einzelfeldvalidierung
for field_name, validator in self.fields.items():
field_value = data.get(field_name)
field_result = validator.validate(field_value, field_name)
if not field_result.is_valid:
result.errors.update(field_result.errors)
result.is_valid = False
result.warnings.update(field_result.warnings)
result.cleaned_data.update(field_result.cleaned_data)
# Benutzerdefinierte Validierung
if result.is_valid:
for custom_validator in self.custom_validators:
try:
custom_result = custom_validator(result.cleaned_data)
if isinstance(custom_result, ValidationResult):
if not custom_result.is_valid:
result.errors.update(custom_result.errors)
result.is_valid = False
result.warnings.update(custom_result.warnings)
except Exception as e:
logger.error(f"Fehler bei benutzerdefinierter Validierung: {str(e)}")
result.add_error("form", "Unerwarteter Validierungsfehler")
return result
# Vordefinierte Formular-Validatoren
def get_user_registration_validator() -> FormValidator:
"""Validator für Benutzerregistrierung"""
return FormValidator() \
.add_field("username", StringValidator(min_length=3, max_length=50, required=True)) \
.add_field("email", EmailValidator(required=True)) \
.add_field("password", StringValidator(min_length=8, required=True)) \
.add_field("password_confirm", StringValidator(min_length=8, required=True)) \
.add_field("name", StringValidator(min_length=2, max_length=100, required=True)) \
.add_custom_validator(lambda data: _validate_password_match(data))
def get_job_creation_validator() -> FormValidator:
"""Validator für Job-Erstellung"""
return FormValidator() \
.add_field("name", StringValidator(min_length=1, max_length=200, required=True)) \
.add_field("description", StringValidator(max_length=500)) \
.add_field("printer_id", IntegerValidator(min_value=1, required=True)) \
.add_field("duration_minutes", IntegerValidator(min_value=1, max_value=1440, required=True)) \
.add_field("start_at", DateTimeValidator(min_date=datetime.now())) \
.add_field("file", FileValidator(required=True))
def get_printer_creation_validator() -> FormValidator:
"""Validator für Drucker-Erstellung"""
return FormValidator() \
.add_field("name", StringValidator(min_length=1, max_length=100, required=True)) \
.add_field("model", StringValidator(max_length=100)) \
.add_field("location", StringValidator(max_length=100)) \
.add_field("ip_address", StringValidator(pattern=r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$')) \
.add_field("mac_address", StringValidator(pattern=r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$', required=True)) \
.add_field("plug_ip", StringValidator(pattern=r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', required=True)) \
.add_field("plug_username", StringValidator(min_length=1, required=True)) \
.add_field("plug_password", StringValidator(min_length=1, required=True))
def get_guest_request_validator() -> FormValidator:
"""Validator für Gastanfragen"""
return FormValidator() \
.add_field("name", StringValidator(min_length=2, max_length=100, required=True)) \
.add_field("email", EmailValidator()) \
.add_field("reason", StringValidator(min_length=10, max_length=500, required=True)) \
.add_field("duration_minutes", IntegerValidator(min_value=5, max_value=480, required=True)) \
.add_field("copies", IntegerValidator(min_value=1, max_value=10)) \
.add_field("file", FileValidator(required=True)) \
.set_rate_limit("guest_request")
def _validate_password_match(data: Dict[str, Any]) -> ValidationResult:
"""Validiert, ob Passwörter übereinstimmen"""
result = ValidationResult()
password = data.get("password")
password_confirm = data.get("password_confirm")
if password != password_confirm:
result.add_error("password_confirm", "Passwörter stimmen nicht überein")
return result
# Decorator für automatische Formularvalidierung
def validate_form(validator_func: Callable[[], FormValidator]):
"""Decorator für automatische Formularvalidierung"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
try:
# Validator erstellen
validator = validator_func()
# Daten aus Request extrahieren
if request.is_json:
data = request.get_json() or {}
else:
data = dict(request.form)
# Dateien hinzufügen
for key, file in request.files.items():
data[key] = file
# Validierung durchführen
validation_result = validator.validate(data)
# Bei Fehlern JSON-Response zurückgeben
if not validation_result.is_valid:
logger.warning(f"Validierungsfehler für {request.endpoint}: {validation_result.errors}")
return jsonify({
"success": False,
"errors": validation_result.errors,
"warnings": validation_result.warnings
}), 400
# Gereinigte Daten an die Request anhängen
request.validated_data = validation_result.cleaned_data
request.validation_warnings = validation_result.warnings
return f(*args, **kwargs)
except Exception as e:
logger.error(f"Fehler bei Formularvalidierung: {str(e)}")
return jsonify({
"success": False,
"errors": {"form": ["Unerwarteter Validierungsfehler"]}
}), 500
return decorated_function
return decorator
# JavaScript für Client-seitige Validierung
def get_client_validation_js() -> str:
"""Generiert JavaScript für Client-seitige Validierung"""
return """
class FormValidator {
constructor(formId, validationRules = {}) {
this.form = document.getElementById(formId);
this.rules = validationRules;
this.errors = {};
this.setupEventListeners();
}
setupEventListeners() {
if (!this.form) return;
// Echtzeit-Validierung bei Eingabe
this.form.addEventListener('input', (e) => {
this.validateField(e.target);
});
// Formular-Submission
this.form.addEventListener('submit', (e) => {
if (!this.validateForm()) {
e.preventDefault();
}
});
}
validateField(field) {
const fieldName = field.name;
const value = field.value;
const rule = this.rules[fieldName];
if (!rule) return true;
this.clearFieldError(field);
// Required-Prüfung
if (rule.required && (!value || value.trim() === '')) {
this.addFieldError(field, 'Dieses Feld ist erforderlich.');
return false;
}
// Längenprüfung
if (rule.minLength && value.length < rule.minLength) {
this.addFieldError(field, `Mindestlänge: ${rule.minLength} Zeichen`);
return false;
}
if (rule.maxLength && value.length > rule.maxLength) {
this.addFieldError(field, `Maximallänge: ${rule.maxLength} Zeichen`);
return false;
}
// Pattern-Prüfung
if (rule.pattern && !new RegExp(rule.pattern).test(value)) {
this.addFieldError(field, rule.patternMessage || 'Format ist ungültig');
return false;
}
// Email-Prüfung
if (rule.type === 'email' && value) {
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
if (!emailPattern.test(value)) {
this.addFieldError(field, 'Bitte geben Sie eine gültige E-Mail-Adresse ein');
return false;
}
}
// Custom Validierung
if (rule.customValidator) {
const customResult = rule.customValidator(value, field);
if (customResult !== true) {
this.addFieldError(field, customResult);
return false;
}
}
return true;
}
validateForm() {
let isValid = true;
this.errors = {};
// Alle Felder validieren
const fields = this.form.querySelectorAll('input, textarea, select');
fields.forEach(field => {
if (!this.validateField(field)) {
isValid = false;
}
});
// Custom Form-Validierung
if (this.rules._formValidator) {
const formData = new FormData(this.form);
const customResult = this.rules._formValidator(formData, this.form);
if (customResult !== true) {
this.addFormError(customResult);
isValid = false;
}
}
return isValid;
}
addFieldError(field, message) {
const fieldName = field.name;
// Error-Container finden oder erstellen
let errorContainer = field.parentNode.querySelector('.field-error');
if (!errorContainer) {
errorContainer = document.createElement('div');
errorContainer.className = 'field-error text-red-600 text-sm mt-1';
errorContainer.setAttribute('role', 'alert');
errorContainer.setAttribute('aria-live', 'polite');
field.parentNode.appendChild(errorContainer);
}
errorContainer.textContent = message;
field.classList.add('border-red-500');
field.setAttribute('aria-invalid', 'true');
// Für Screen Reader
if (!field.getAttribute('aria-describedby')) {
const errorId = `error-${fieldName}-${Date.now()}`;
errorContainer.id = errorId;
field.setAttribute('aria-describedby', errorId);
}
this.errors[fieldName] = message;
}
clearFieldError(field) {
const errorContainer = field.parentNode.querySelector('.field-error');
if (errorContainer) {
errorContainer.remove();
}
field.classList.remove('border-red-500');
field.removeAttribute('aria-invalid');
field.removeAttribute('aria-describedby');
delete this.errors[field.name];
}
addFormError(message) {
let formErrorContainer = this.form.querySelector('.form-error');
if (!formErrorContainer) {
formErrorContainer = document.createElement('div');
formErrorContainer.className = 'form-error bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4';
formErrorContainer.setAttribute('role', 'alert');
this.form.insertBefore(formErrorContainer, this.form.firstChild);
}
formErrorContainer.textContent = message;
}
clearFormErrors() {
const formErrorContainer = this.form.querySelector('.form-error');
if (formErrorContainer) {
formErrorContainer.remove();
}
}
showServerErrors(errors) {
// Server-Fehler anzeigen
for (const [fieldName, messages] of Object.entries(errors)) {
const field = this.form.querySelector(`[name="${fieldName}"]`);
if (field && messages.length > 0) {
this.addFieldError(field, messages[0]);
}
}
}
}
// Utility-Funktionen
window.FormValidationUtils = {
// Passwort-Stärke prüfen
validatePasswordStrength: (password) => {
if (password.length < 8) return 'Passwort muss mindestens 8 Zeichen lang sein';
if (!/[A-Z]/.test(password)) return 'Passwort muss mindestens einen Großbuchstaben enthalten';
if (!/[a-z]/.test(password)) return 'Passwort muss mindestens einen Kleinbuchstaben enthalten';
if (!/[0-9]/.test(password)) return 'Passwort muss mindestens eine Zahl enthalten';
return true;
},
// Passwort-Bestätigung prüfen
validatePasswordConfirm: (password, confirm) => {
return password === confirm ? true : 'Passwörter stimmen nicht überein';
},
// Datei-Validierung
validateFile: (file, allowedTypes = [], maxSizeMB = 10) => {
if (!file) return 'Bitte wählen Sie eine Datei aus';
const fileType = file.name.split('.').pop().toLowerCase();
if (allowedTypes.length > 0 && !allowedTypes.includes(fileType)) {
return `Nur folgende Dateiformate sind erlaubt: ${allowedTypes.join(', ')}`;
}
if (file.size > maxSizeMB * 1024 * 1024) {
return `Datei ist zu groß. Maximum: ${maxSizeMB} MB`;
}
return true;
}
};
"""
def render_validation_errors(errors: Dict[str, List[str]]) -> str:
"""Rendert Validierungsfehler als HTML"""
if not errors:
return ""
html_parts = ['<div class="validation-errors">']
for field, messages in errors.items():
for message in messages:
html_parts.append(
f'<div class="error-message bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-2" role="alert">'
f'<strong>{field}:</strong> {html.escape(message)}'
f'</div>'
)
html_parts.append('</div>')
return '\n'.join(html_parts)