Compare commits

...

7 Commits

9 changed files with 132 additions and 31 deletions
+1 -2
View File
@@ -230,7 +230,6 @@ jobs:
cp backup.sh release-bundle/backup.sh cp backup.sh release-bundle/backup.sh
cp config.json release-bundle/config.json cp config.json release-bundle/config.json
cp update.sh release-bundle/update.sh cp update.sh release-bundle/update.sh
cp init-admin.sh release-bundle/init-admin.sh
# Multitenant scripts & docs # Multitenant scripts & docs
cp docker-compose-multitenant.yml release-bundle/docker-compose-multitenant.yml cp docker-compose-multitenant.yml release-bundle/docker-compose-multitenant.yml
@@ -239,7 +238,7 @@ jobs:
cp MULTITENANT_DEPLOYMENT.md release-bundle/MULTITENANT_DEPLOYMENT.md cp MULTITENANT_DEPLOYMENT.md release-bundle/MULTITENANT_DEPLOYMENT.md
cp MULTITENANT_PYTHON_API.md release-bundle/MULTITENANT_PYTHON_API.md cp MULTITENANT_PYTHON_API.md release-bundle/MULTITENANT_PYTHON_API.md
chmod +x release-bundle/start.sh release-bundle/stop.sh release-bundle/restart.sh release-bundle/backup.sh release-bundle/update.sh release-bundle/init-admin.sh release-bundle/manage-tenant.sh release-bundle/run-tenant-cmd.sh chmod +x release-bundle/start.sh release-bundle/stop.sh release-bundle/restart.sh release-bundle/backup.sh release-bundle/update.sh release-bundle/manage-tenant.sh release-bundle/run-tenant-cmd.sh
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle . tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
- name: Create or update GitHub Release - name: Create or update GitHub Release
+6 -16
View File
@@ -45,8 +45,6 @@ Das Inventarsystem stellt folgende Wartungsskripte bereit:
| `stop.sh` | Dienste stoppen | | `stop.sh` | Dienste stoppen |
| `restart.sh` | Dienste neu starten | | `restart.sh` | Dienste neu starten |
| `build-nuitka.sh` | Standalone-Build der App mit Nuitka erstellen | | `build-nuitka.sh` | Standalone-Build der App mit Nuitka erstellen |
| `init-admin.sh` | Initialisiert Admin-User wenn DB leer ist |
| `create-user.sh` | Erstellt neue User per Kommandozeile |
| `Backup-DB.py` | Manuelles DB-Backup | | `Backup-DB.py` | Manuelles DB-Backup |
| `restore.sh` | Backup wiederherstellen | | `restore.sh` | Backup wiederherstellen |
| `manage-version.sh` | Versionssteuerung | | `manage-version.sh` | Versionssteuerung |
@@ -286,27 +284,19 @@ https://[SERVER-IP]
Wenn die Datenbank leer ist (beim ersten Start), erstellen Sie einen Admin-User: Wenn die Datenbank leer ist (beim ersten Start), erstellen Sie einen Admin-User:
**Option 1: Automatisiert beim Start** **Option 1: Interaktiv**
```bash
./init-admin.sh
```
Mit benutzerdefinierten Daten:
```bash
./init-admin.sh myadmin mypassword123 Max Mustermann
```
**Option 2: Interaktiv**
```bash ```bash
cd Web && python3 generate_user.py cd Web && python3 generate_user.py
``` ```
**Option 3: Per Kommandozeile** **Option 2: Multitenant-Tenant erstellen**
```bash ```bash
./create-user.sh admin password123456 Admin User --admin sudo ./manage-tenant.sh add <tenant-name> <port>
``` ```
**Option 4: Direkt in MongoDB (Notlösung)** Dieser Weg erzeugt beim Anlegen eines neuen Tenants einen Standard-Admin für den Tenant.
**Option 3: Direkt in MongoDB (Notlösung)**
```bash ```bash
docker exec inventarsystem-mongodb mongosh --eval " docker exec inventarsystem-mongodb mongosh --eval "
db.users.insertOne({ db.users.insertOne({
+32 -1
View File
@@ -50,6 +50,7 @@ import socket
import io import io
import html import html
import logging import logging
from logging.handlers import RotatingFileHandler
import secrets import secrets
import importlib import importlib
try: try:
@@ -76,10 +77,19 @@ from data_protection import (
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
import settings as cfg import settings as cfg
from settings import MongoClient from settings import MongoClient
from tenant import get_tenant_context
app = Flask(__name__, static_folder='static') # Correctly set static folder app = Flask(__name__, static_folder='static') # Correctly set static folder
app.logger.setLevel(logging.WARNING) app.logger.setLevel(logging.INFO)
if not os.path.exists(cfg.LOGS_FOLDER):
os.makedirs(cfg.LOGS_FOLDER, exist_ok=True)
log_file_path = os.path.join(cfg.LOGS_FOLDER, 'application.log')
file_handler = RotatingFileHandler(log_file_path, maxBytes=10 * 1024 * 1024, backupCount=5, encoding='utf-8')
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(name)s %(message)s'))
app.logger.handlers = []
app.logger.addHandler(file_handler)
app.secret_key = cfg.SECRET_KEY app.secret_key = cfg.SECRET_KEY
app.debug = cfg.DEBUG app.debug = cfg.DEBUG
app.config['UPLOAD_FOLDER'] = cfg.UPLOAD_FOLDER app.config['UPLOAD_FOLDER'] = cfg.UPLOAD_FOLDER
@@ -1092,6 +1102,15 @@ def inject_version():
client.close() client.close()
current_module = _get_current_module(request.path) current_module = _get_current_module(request.path)
current_tenant_db = MONGODB_DB
current_tenant_id = None
try:
ctx = get_tenant_context()
if ctx:
current_tenant_db = ctx.db_name or current_tenant_db
current_tenant_id = ctx.tenant_id
except Exception:
current_tenant_db = MONGODB_DB
return { return {
'APP_VERSION': APP_VERSION, 'APP_VERSION': APP_VERSION,
@@ -1104,6 +1123,8 @@ def inject_version():
'is_admin': is_admin, 'is_admin': is_admin,
'unread_notification_count': unread_notification_count, 'unread_notification_count': unread_notification_count,
'current_permissions': current_permissions, 'current_permissions': current_permissions,
'current_tenant_db': current_tenant_db,
'current_tenant_id': current_tenant_id,
'permission_action_options': PERMISSION_ACTION_OPTIONS, 'permission_action_options': PERMISSION_ACTION_OPTIONS,
'permission_page_options': PERMISSION_PAGE_OPTIONS, 'permission_page_options': PERMISSION_PAGE_OPTIONS,
'permission_presets': us.get_permission_preset_definitions(), 'permission_presets': us.get_permission_preset_definitions(),
@@ -4036,13 +4057,22 @@ def login():
if request.method == 'POST': if request.method == 'POST':
username = request.form['username'] username = request.form['username']
password = request.form['password'] password = request.form['password']
ctx = get_tenant_context()
current_tenant_id = ctx.tenant_id if ctx else None
current_tenant_db = ctx.db_name if ctx else cfg.MONGODB_DB
app.logger.info(f"Login attempt: username={username!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={request.remote_addr}")
app.logger.info(f"Debug login context: headers={dict(request.headers)} tenant_config={ctx.config if ctx else None} remote_addr={request.remote_addr} host={request.host}")
app.logger.info(f"Raw login payload: username={username!r} password={password!r}")
app.logger.info(f"Active MongoDB config: uri={getattr(cfg, 'MONGODB_URI', None)!r} host={cfg.MONGODB_HOST!r} port={cfg.MONGODB_PORT!r} default_db={cfg.MONGODB_DB!r}")
if not username or not password: if not username or not password:
app.logger.warning(f"Login blocked: missing credentials tenant={current_tenant_id or 'default'} host={request.host} ip={request.remote_addr}")
flash('Bitte alle Felder ausfüllen', 'error') flash('Bitte alle Felder ausfüllen', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
user = us.check_nm_pwd(username, password) user = us.check_nm_pwd(username, password)
if user: if user:
app.logger.info(f"Login success: username={username!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={request.remote_addr}")
session['username'] = username session['username'] = username
is_admin_user = bool(user.get('Admin', False)) is_admin_user = bool(user.get('Admin', False))
session['admin'] = is_admin_user session['admin'] = is_admin_user
@@ -4063,6 +4093,7 @@ def login():
else: else:
return redirect(url_for('home')) return redirect(url_for('home'))
else: else:
app.logger.warning(f"Login failed: username={username!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={request.remote_addr}")
flash('Ungültige Anmeldedaten', 'error') flash('Ungültige Anmeldedaten', 'error')
get_flashed_messages() get_flashed_messages()
return render_template('login.html') return render_template('login.html')
+7 -1
View File
@@ -131,11 +131,15 @@ PORT = _get(_conf, ['port'], DEFAULTS['port'])
MONGODB_HOST = _get(_conf, ['mongodb', 'host'], DEFAULTS['mongodb']['host']) MONGODB_HOST = _get(_conf, ['mongodb', 'host'], DEFAULTS['mongodb']['host'])
MONGODB_PORT = _get(_conf, ['mongodb', 'port'], DEFAULTS['mongodb']['port']) MONGODB_PORT = _get(_conf, ['mongodb', 'port'], DEFAULTS['mongodb']['port'])
MONGODB_DB = _get(_conf, ['mongodb', 'db'], DEFAULTS['mongodb']['db']) MONGODB_DB = _get(_conf, ['mongodb', 'db'], DEFAULTS['mongodb']['db'])
MONGODB_URI = _get(_conf, ['mongodb', 'uri'], '')
# Optional environment overrides for containerized/runtime deployments. # Optional environment overrides for containerized/runtime deployments.
MONGODB_HOST = os.getenv('INVENTAR_MONGODB_HOST', MONGODB_HOST) MONGODB_HOST = os.getenv('INVENTAR_MONGODB_HOST', MONGODB_HOST)
MONGODB_PORT = int(os.getenv('INVENTAR_MONGODB_PORT', str(MONGODB_PORT))) MONGODB_PORT = int(os.getenv('INVENTAR_MONGODB_PORT', str(MONGODB_PORT)))
MONGODB_DB = os.getenv('INVENTAR_MONGODB_DB', MONGODB_DB) MONGODB_DB = os.getenv('INVENTAR_MONGODB_DB', MONGODB_DB)
MONGODB_URI = os.getenv('INVENTAR_MONGODB_URI', os.getenv('MONGO_URI', MONGODB_URI))
if isinstance(MONGODB_URI, str):
MONGODB_URI = MONGODB_URI.strip() or None
MONGODB_MAX_POOL_SIZE = _get_int_env('INVENTAR_MONGODB_MAX_POOL_SIZE', 20) MONGODB_MAX_POOL_SIZE = _get_int_env('INVENTAR_MONGODB_MAX_POOL_SIZE', 20)
MONGODB_MIN_POOL_SIZE = _get_int_env('INVENTAR_MONGODB_MIN_POOL_SIZE', 0) MONGODB_MIN_POOL_SIZE = _get_int_env('INVENTAR_MONGODB_MIN_POOL_SIZE', 0)
MONGODB_MAX_IDLE_TIME_MS = _get_int_env('INVENTAR_MONGODB_MAX_IDLE_TIME_MS', 300000) MONGODB_MAX_IDLE_TIME_MS = _get_int_env('INVENTAR_MONGODB_MAX_IDLE_TIME_MS', 300000)
@@ -318,7 +322,9 @@ def MongoClient(*args, **kwargs):
} }
client_kwargs.update(kwargs) client_kwargs.update(kwargs)
if len(args) >= 2 and not explicit_host and not explicit_port: if MONGODB_URI and len(args) == 0 and not explicit_host and not explicit_port:
mongo_args = (MONGODB_URI,)
elif len(args) >= 2 and not explicit_host and not explicit_port:
mongo_args = args mongo_args = args
else: else:
mongo_args = (host, port) mongo_args = (host, port)
+15 -1
View File
@@ -192,6 +192,17 @@
flex-shrink: 0; flex-shrink: 0;
} }
.navbar-text.tenant-badge {
font-size: 0.82rem;
color: rgba(255,255,255,0.9);
background: rgba(255,255,255,0.12);
border: 1px solid rgba(255,255,255,0.18);
padding: 0.25rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
margin-right: 0.75rem;
}
.navbar-brand::before { .navbar-brand::before {
content: attr(data-icon); content: attr(data-icon);
margin-right: 6px; margin-right: 6px;
@@ -1113,7 +1124,10 @@
<button class="function-search-btn" type="submit">Los</button> <button class="function-search-btn" type="submit">Los</button>
</form> </form>
</div> </div>
<span class="navbar-text text-light me-3">{{ session['username'] }}</span> {% if current_tenant_db %}
<span class="navbar-text tenant-badge" title="Aktive Tenant-Datenbank">{{ current_tenant_db }}</span>
{% endif %}
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
<div class="dropdown me-2 user-menu-wrap"> <div class="dropdown me-2 user-menu-wrap">
<button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true"> <button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
👤 👤
+9
View File
@@ -14,6 +14,15 @@
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-6"> <div class="col-md-6">
<h2 class="text-center">Login</h2> <h2 class="text-center">Login</h2>
{% if current_tenant_id or current_tenant_db %}
<div class="alert alert-info text-center small mb-4" role="alert">
{% if current_tenant_id %}
Aktiver Tenant: <strong>{{ current_tenant_id }}</strong> &middot; Datenbank: <strong>{{ current_tenant_db }}</strong>
{% else %}
Aktive Datenbank: <strong>{{ current_tenant_db }}</strong>
{% endif %}
</div>
{% endif %}
<form method="POST" action="{{ url_for('login') }}"> <form method="POST" action="{{ url_for('login') }}">
<div class="mb-3"> <div class="mb-3">
<label for="username" class="form-label">Username</label> <label for="username" class="form-label">Username</label>
+9
View File
@@ -123,12 +123,17 @@ class TenantContext:
host = request.host.lower() host = request.host.lower()
_, port = _parse_port_from_host(host) _, port = _parse_port_from_host(host)
self.port = port self.port = port
logger.info(f"Tenant resolution start: request.host={host} request.headers={dict(request.headers)}")
if port: if port:
tenant_from_port = _tenant_id_for_port(port) tenant_from_port = _tenant_id_for_port(port)
if tenant_from_port: if tenant_from_port:
self.tenant_id = tenant_from_port self.tenant_id = tenant_from_port
self.config = get_tenant_config(tenant_from_port) self.config = get_tenant_config(tenant_from_port)
logger.info(
f"Tenant resolution by port: host={host} port={port} tenant={tenant_from_port} config={self.config}"
)
return self._get_db_name(tenant_from_port) return self._get_db_name(tenant_from_port)
logger.info(f"Tenant port not mapped: host={host} port={port}")
# Priority 3: Subdomain extraction # Priority 3: Subdomain extraction
parts = host.split('.') parts = host.split('.')
@@ -144,7 +149,11 @@ class TenantContext:
self.subdomain = potential_subdomain self.subdomain = potential_subdomain
self.tenant_id = potential_subdomain self.tenant_id = potential_subdomain
self.config = get_tenant_config(potential_subdomain) self.config = get_tenant_config(potential_subdomain)
logger.info(
f"Tenant resolution by subdomain: host={host} tenant={potential_subdomain} config={self.config}"
)
return self._get_db_name(potential_subdomain) return self._get_db_name(potential_subdomain)
logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
# Fallback to default tenant if no tenant identifier found. # Fallback to default tenant if no tenant identifier found.
# If no explicit 'default' tenant config exists, use configured MongoDB DB. # If no explicit 'default' tenant config exists, use configured MongoDB DB.
+50 -7
View File
@@ -12,6 +12,7 @@ Provides methods for creating, validating, and retrieving user information.
''' '''
import hashlib import hashlib
import copy import copy
import logging
import re import re
import secrets import secrets
import string import string
@@ -19,6 +20,8 @@ from bson.objectid import ObjectId
import settings as cfg import settings as cfg
from settings import MongoClient from settings import MongoClient
logger = logging.getLogger(__name__)
def normalize_student_card_id(card_id): def normalize_student_card_id(card_id):
"""Normalize student card IDs for reliable lookup.""" """Normalize student card IDs for reliable lookup."""
@@ -421,25 +424,65 @@ def check_nm_pwd(username, password):
""" """
db_name = cfg.MONGODB_DB db_name = cfg.MONGODB_DB
tenant_db = None tenant_db = None
ctx = None
try: try:
from tenant import get_tenant_context from tenant import get_tenant_context
ctx = get_tenant_context() ctx = get_tenant_context()
if ctx and ctx.tenant_id: if ctx and ctx.tenant_id:
tenant_db = ctx.db_name or ctx.resolve_tenant() tenant_db = ctx.db_name or ctx.resolve_tenant()
db_name = tenant_db db_name = tenant_db
except Exception: except Exception as exc:
pass logger.exception(f"Failed to resolve tenant context in check_nm_pwd: {exc}")
logger.info(
"check_nm_pwd start: username=%r tenant=%r db=%r host=%r port=%r uri=%r",
username,
ctx.tenant_id if ctx else None,
db_name,
cfg.MONGODB_HOST,
cfg.MONGODB_PORT,
getattr(cfg, 'MONGODB_URI', None),
)
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try: try:
hashed_password = hashing(password) hashed_password = hashing(password)
logger.info("check_nm_pwd password hash for username=%r: %s", username, hashed_password)
def find_user_in_db(database_name): db = client[db_name]
db = client[database_name] users = db['users']
users = db['users'] user_record = users.find_one({'$or': [{'Username': username}, {'username': username}]})
return users.find_one({'Username': username, 'Password': hashed_password}) or users.find_one({'username': username, 'Password': hashed_password})
return find_user_in_db(db_name) if user_record is None:
try:
available_dbs = client.list_database_names()
logger.warning(
"No user document in tenant database %r for username=%r. Available databases=%s",
db_name,
username,
available_dbs,
)
except Exception as exc:
logger.exception("Could not list databases during failed login check: %s", exc)
return None
logger.info("Found user document for username=%r in db=%r: %s", username, db_name, user_record)
stored_password = user_record.get('Password') or user_record.get('password')
if stored_password is None:
logger.warning("User document for username=%r in db=%r has no password field", username, db_name)
return None
if stored_password != hashed_password:
logger.warning(
"Password mismatch for username=%r in db=%r: provided_hash=%s stored_hash=%s",
username,
db_name,
hashed_password,
stored_password,
)
return None
return user_record
finally: finally:
client.close() client.close()
+2 -2
View File
@@ -368,7 +368,7 @@ download_and_extract_bundle() {
done done
# Ensure executable permissions on all copied scripts # Ensure executable permissions on all copied scripts
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/init-admin.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true 2>/dev/null || true chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true 2>/dev/null || true
chmod +x "$PROJECT_DIR"/manage-tenant.sh "$PROJECT_DIR"/run-tenant-cmd.sh 2>/dev/null || true chmod +x "$PROJECT_DIR"/manage-tenant.sh "$PROJECT_DIR"/run-tenant-cmd.sh 2>/dev/null || true
if [ ! -f "$PROJECT_DIR/config.json" ] && [ -f "$tmp_dir/config.json" ]; then if [ ! -f "$PROJECT_DIR/config.json" ] && [ -f "$tmp_dir/config.json" ]; then
@@ -376,7 +376,7 @@ download_and_extract_bundle() {
log_message "Installed default config.json from release bundle" log_message "Installed default config.json from release bundle"
fi fi
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/init-admin.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true
} }
deploy() { deploy() {