diff --git a/.env.example b/.env.example index 5566960..1e76286 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,26 @@ DJANGO_SECRET_KEY=change-me DJANGO_DEBUG=1 DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +DJANGO_SECURE_COOKIES=0 +DJANGO_SECURE_SSL_REDIRECT=0 +SESSION_IDLE_TIMEOUT_SECONDS=1800 +SENSITIVE_ACTION_REAUTH_SECONDS=1200 -POSTGRES_DB=onoff -POSTGRES_USER=onoff -POSTGRES_PASSWORD=onoff +POSTGRES_DB=workdock +POSTGRES_USER=workdock +POSTGRES_PASSWORD=workdock POSTGRES_HOST=db POSTGRES_PORT=5432 REDIS_URL=redis://redis:6379/0 CELERY_TASK_ALWAYS_EAGER=0 +RATE_LIMIT_ENABLED=1 +RATE_LIMIT_LOGIN_LIMIT=8 +RATE_LIMIT_LOGIN_WINDOW=300 +RATE_LIMIT_PASSWORD_RESET_LIMIT=5 +RATE_LIMIT_PASSWORD_RESET_WINDOW=600 +RATE_LIMIT_ADMIN_ACTION_LIMIT=20 +RATE_LIMIT_ADMIN_ACTION_WINDOW=300 EMAIL_HOST=mailhog EMAIL_PORT=1025 diff --git a/PRODUCTIZATION_ROADMAP.md b/PRODUCTIZATION_ROADMAP.md index 978c559..c05a29e 100644 --- a/PRODUCTIZATION_ROADMAP.md +++ b/PRODUCTIZATION_ROADMAP.md @@ -183,7 +183,7 @@ Examples already identified: - former TUBCO-specific portal title kept only as historical baseline context - logo asset references - invitation email wording mentioning TUBCO -- welcome-email defaults mentioning TUB/CO +- historical product text references that still describe the original TUBCO baseline - fixed letterhead file assumptions These should move into configuration progressively, not all at once in one risky rewrite. diff --git a/backend/config/settings.py b/backend/config/settings.py index cfc5d11..045551c 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -1,4 +1,5 @@ import os +import sys from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent @@ -25,6 +26,28 @@ CSRF_COOKIE_SECURE = _secure_cookies DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.getenv('DJANGO_DATA_UPLOAD_MAX_MEMORY_SIZE', str(10 * 1024 * 1024))) FILE_UPLOAD_MAX_MEMORY_SIZE = int(os.getenv('DJANGO_FILE_UPLOAD_MAX_MEMORY_SIZE', str(5 * 1024 * 1024))) +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'workdock-default-cache', + } +} + +SESSION_COOKIE_AGE = int(os.getenv('DJANGO_SESSION_COOKIE_AGE', str(60 * 60 * 8))) +SESSION_SAVE_EVERY_REQUEST = os.getenv('DJANGO_SESSION_SAVE_EVERY_REQUEST', '1') == '1' +SESSION_EXPIRE_AT_BROWSER_CLOSE = os.getenv('DJANGO_SESSION_EXPIRE_AT_BROWSER_CLOSE', '1') == '1' +SESSION_IDLE_TIMEOUT_SECONDS = int(os.getenv('SESSION_IDLE_TIMEOUT_SECONDS', str(60 * 30))) +SENSITIVE_ACTION_REAUTH_SECONDS = int(os.getenv('SENSITIVE_ACTION_REAUTH_SECONDS', str(60 * 20))) + +RATE_LIMIT_LOGIN_LIMIT = int(os.getenv('RATE_LIMIT_LOGIN_LIMIT', '8')) +RATE_LIMIT_LOGIN_WINDOW = int(os.getenv('RATE_LIMIT_LOGIN_WINDOW', '300')) +RATE_LIMIT_PASSWORD_RESET_LIMIT = int(os.getenv('RATE_LIMIT_PASSWORD_RESET_LIMIT', '5')) +RATE_LIMIT_PASSWORD_RESET_WINDOW = int(os.getenv('RATE_LIMIT_PASSWORD_RESET_WINDOW', '600')) +RATE_LIMIT_ADMIN_ACTION_LIMIT = int(os.getenv('RATE_LIMIT_ADMIN_ACTION_LIMIT', '20')) +RATE_LIMIT_ADMIN_ACTION_WINDOW = int(os.getenv('RATE_LIMIT_ADMIN_ACTION_WINDOW', '300')) +RATE_LIMIT_ENABLED = os.getenv('RATE_LIMIT_ENABLED', '1') == '1' +RUN_SECURITY_CHECKS_DURING_TESTS = os.getenv('RUN_SECURITY_CHECKS_DURING_TESTS', '0') == '1' + INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', @@ -41,8 +64,10 @@ MIDDLEWARE = [ 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', 'workflows.middleware.RequestIDMiddleware', + 'workflows.middleware.RateLimitMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'workflows.middleware.AuthSessionHardeningMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'workflows.middleware.TrialModeMiddleware', @@ -72,9 +97,9 @@ ASGI_APPLICATION = 'config.asgi.application' DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', - 'NAME': os.getenv('POSTGRES_DB', 'onoff'), - 'USER': os.getenv('POSTGRES_USER', 'onoff'), - 'PASSWORD': os.getenv('POSTGRES_PASSWORD', 'onoff'), + 'NAME': os.getenv('POSTGRES_DB', 'workdock'), + 'USER': os.getenv('POSTGRES_USER', 'workdock'), + 'PASSWORD': os.getenv('POSTGRES_PASSWORD', 'workdock'), 'HOST': os.getenv('POSTGRES_HOST', 'db'), 'PORT': int(os.getenv('POSTGRES_PORT', '5432')), } diff --git a/backend/config/urls.py b/backend/config/urls.py index 12c21f2..81ef653 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -4,7 +4,7 @@ from django.contrib import admin from django.contrib.auth import views as auth_views from django.urls import include, path -from workflows.forms import AppAuthenticationForm, AppPasswordResetForm, AppSetPasswordForm +from workflows.forms import AppAuthenticationForm, AppPasswordChangeForm, AppPasswordResetForm, AppSetPasswordForm urlpatterns = [ path('admin/', admin.site.urls), @@ -24,6 +24,19 @@ urlpatterns = [ auth_views.PasswordResetView.as_view(template_name='workflows/auth/password_reset_form.html', form_class=AppPasswordResetForm), name='password_reset', ), + path( + 'accounts/password_change/', + auth_views.PasswordChangeView.as_view( + template_name='workflows/auth/password_change_form.html', + form_class=AppPasswordChangeForm, + ), + name='password_change', + ), + path( + 'accounts/password_change/done/', + auth_views.PasswordChangeDoneView.as_view(template_name='workflows/auth/password_change_done.html'), + name='password_change_done', + ), path( 'accounts/password_reset/done/', auth_views.PasswordResetDoneView.as_view(template_name='workflows/auth/password_reset_done.html'), diff --git a/backend/workflows/app_registry.py b/backend/workflows/app_registry.py index 9e01c2b..bf46d01 100644 --- a/backend/workflows/app_registry.py +++ b/backend/workflows/app_registry.py @@ -8,6 +8,9 @@ from django.utils.translation import gettext_lazy as _ from .models import PortalAppConfig from .roles import ROLE_ADMIN, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, get_user_role_key, user_has_capability +# The registry controls discoverability and packaging posture for apps. +# Actual authorization still comes from role capabilities in roles.py. + @dataclass(frozen=True) class AppDefinition: @@ -188,6 +191,8 @@ APP_DEFINITIONS: tuple[AppDefinition, ...] = ( DEFAULT_ROLE_VISIBILITY = { + # These defaults are product recommendations for fresh deployments. + # Saved PortalAppConfig rows can override them per customer installation. 'onboarding': { ROLE_SUPER_ADMIN: True, ROLE_ADMIN: True, diff --git a/backend/workflows/apps.py b/backend/workflows/apps.py index bf4f6e7..1706b63 100644 --- a/backend/workflows/apps.py +++ b/backend/workflows/apps.py @@ -7,3 +7,4 @@ class WorkflowsConfig(AppConfig): def ready(self): from . import signals # noqa: F401 + from . import checks # noqa: F401 diff --git a/backend/workflows/backup_ops.py b/backend/workflows/backup_ops.py index 6cf5ce3..d6516cf 100644 --- a/backend/workflows/backup_ops.py +++ b/backend/workflows/backup_ops.py @@ -18,6 +18,9 @@ from django.utils.translation import gettext as _ from .models import WorkflowConfig from .services import delete_from_nextcloud, upload_to_nextcloud +# Backup bundles are local-first. Remote copy is a secondary delivery path and +# must never replace the ability to verify/restore the local bundle directly. + def _backup_root() -> Path: root = Path(settings.BACKUP_OUTPUT_DIR) @@ -115,6 +118,8 @@ def list_backup_bundles() -> list[dict]: def latest_backup_health_snapshot(stale_after_hours: int = 48) -> dict: + # A single snapshot keeps the UI, scheduled command, and future monitoring + # on the same health contract. rows = list_backup_bundles() if not rows: return { @@ -329,7 +334,7 @@ def verify_backup_bundle(backup_name: str) -> dict: env=env, text=True, ).strip() - with tempfile.TemporaryDirectory(prefix='tubco_backup_verify_media_') as tmpdir: + with tempfile.TemporaryDirectory(prefix='workdock_backup_verify_media_') as tmpdir: with tarfile.open(media_archive_path, 'r:gz') as archive: archive.extractall(tmpdir, filter='data') media_dir = Path(tmpdir) / 'media' diff --git a/backend/workflows/branding.py b/backend/workflows/branding.py index 2220c3b..1679a0a 100644 --- a/backend/workflows/branding.py +++ b/backend/workflows/branding.py @@ -10,6 +10,10 @@ from django.utils.translation import get_language from .models import PortalBranding, PortalCompanyConfig, PortalTrialConfig +# Branding is the product/deployment boundary. +# Workdock is the generic default, while stored DB values preserve the current +# customer deployment identity such as TUBCO. + def get_portal_branding() -> PortalBranding: branding, _ = PortalBranding.objects.get_or_create( @@ -118,6 +122,8 @@ def get_portal_logo_url() -> str: return branding.logo_image.url except ValueError: pass + # The fallback asset file is still the historical TUBCO wordmark. A later + # asset refresh can replace the file without changing the branding contract. return static('workflows/img/tubco-logo.svg') @@ -128,6 +134,7 @@ def get_portal_favicon_url() -> str: return branding.favicon_image.url except ValueError: pass + # Same fallback rule as the logo: keep runtime stable now, replace asset later. return static('workflows/img/tubco-logo.svg') diff --git a/backend/workflows/checks.py b/backend/workflows/checks.py new file mode 100644 index 0000000..39b4bfa --- /dev/null +++ b/backend/workflows/checks.py @@ -0,0 +1,57 @@ +import sys + +from django.conf import settings +from django.core.checks import Error, Warning, register + + +@register() +def security_settings_check(app_configs, **kwargs): + # Keep production checks strict in normal runtime, but avoid blocking the + # entire Django test runner before per-test overrides can take effect. + if 'test' in sys.argv and not settings.RUN_SECURITY_CHECKS_DURING_TESTS: + return [] + + issues = [] + + if not settings.DEBUG and settings.SECRET_KEY == 'unsafe-dev-key': + issues.append( + Error( + 'DJANGO_SECRET_KEY is using the development fallback while DEBUG is disabled.', + id='workdock.E001', + ) + ) + + if not settings.DEBUG and not settings.ALLOWED_HOSTS: + issues.append( + Error( + 'ALLOWED_HOSTS must be configured when DEBUG is disabled.', + id='workdock.E002', + ) + ) + + if not settings.DEBUG and not settings.SESSION_COOKIE_SECURE: + issues.append( + Error( + 'Secure session cookies must be enabled when DEBUG is disabled.', + id='workdock.E003', + ) + ) + + if not settings.DEBUG and not settings.CSRF_COOKIE_SECURE: + issues.append( + Error( + 'Secure CSRF cookies must be enabled when DEBUG is disabled.', + id='workdock.E004', + ) + ) + + if not settings.DEBUG and not settings.SECURE_SSL_REDIRECT: + issues.append( + Warning( + 'SECURE_SSL_REDIRECT is disabled while DEBUG is off.', + hint='Enable DJANGO_SECURE_SSL_REDIRECT=1 behind HTTPS-aware proxying.', + id='workdock.W001', + ) + ) + + return issues diff --git a/backend/workflows/forms.py b/backend/workflows/forms.py index 8ddc4d3..fa15b03 100644 --- a/backend/workflows/forms.py +++ b/backend/workflows/forms.py @@ -2,7 +2,7 @@ from django import forms from pathlib import Path from datetime import timedelta from django.contrib.auth import get_user_model, password_validation -from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm, SetPasswordForm +from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, PasswordResetForm, SetPasswordForm from django.utils import timezone from django.utils.translation import get_language, gettext as _, gettext_lazy @@ -123,6 +123,25 @@ class AppSetPasswordForm(SetPasswordForm): ) +class AppPasswordChangeForm(PasswordChangeForm): + old_password = forms.CharField( + label=gettext_lazy('Aktuelles Passwort'), + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}), + ) + new_password1 = forms.CharField( + label=gettext_lazy('Neues Passwort'), + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}), + help_text=password_validation.password_validators_help_text_html(), + ) + new_password2 = forms.CharField( + label=gettext_lazy('Neues Passwort bestätigen'), + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}), + ) + + class UserManagementCreateForm(forms.Form): first_name = forms.CharField(label=_('Vorname'), max_length=150, required=False) last_name = forms.CharField(label=_('Nachname'), max_length=150, required=False) @@ -385,7 +404,7 @@ class OnboardingRequestForm(forms.ModelForm): widget=forms.CheckboxSelectMultiple, ) phone_number_choice = forms.CharField( - label='TUB/CO-Telefon-Direktwahl-Nr. 030 447202 (10-89)', + label='Telefon-Direktwahl', required=False, widget=forms.TextInput(attrs={'placeholder': 'z. B. 030 44720212'}), ) diff --git a/backend/workflows/middleware.py b/backend/workflows/middleware.py index 904ade5..e19f025 100644 --- a/backend/workflows/middleware.py +++ b/backend/workflows/middleware.py @@ -1,6 +1,15 @@ import uuid -from django.shortcuts import render +from django.conf import settings +from django.contrib import messages +from django.contrib.auth import logout +from django.contrib.messages.api import MessageFailure +from django.core.cache import cache +from django.http import HttpResponse +from django.shortcuts import redirect, render +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext as _ from .branding import is_trial_expired, is_trial_mode_enabled from .logging_utils import clear_request_id, set_request_id @@ -29,6 +38,139 @@ class RequestIDMiddleware: return response +class RateLimitMiddleware: + LOGIN_PATHS = ('/accounts/login/',) + PASSWORD_RESET_PATHS = ('/accounts/password_reset/',) + # Keep this list path-prefix based so new platform actions get protected + # without having to wire every single view into a second permission layer. + ADMIN_SENSITIVE_PREFIXES = ( + '/admin-tools/nextcloud/toggle/', + '/admin-tools/email-mode/toggle/', + '/admin-tools/integrations/save', + '/admin-tools/welcome-emails/', + '/admin-tools/branding/save/', + '/admin-tools/company/save/', + '/admin-tools/trial/save/', + '/admin-tools/apps/save/', + '/admin-tools/users/', + '/admin-tools/backups/', + '/requests/delete/', + '/requests/retry/', + ) + + def __init__(self, get_response): + self.get_response = get_response + + def _client_identifier(self, request) -> str: + user = getattr(request, 'user', None) + if getattr(user, 'is_authenticated', False): + return f'user:{user.pk}' + forwarded = (request.META.get('HTTP_X_FORWARDED_FOR') or '').split(',')[0].strip() + remote = forwarded or request.META.get('REMOTE_ADDR') or 'unknown' + return f'ip:{remote}' + + def _match_rule(self, path: str): + if any(path.startswith(prefix) for prefix in self.LOGIN_PATHS): + return ('login', settings.RATE_LIMIT_LOGIN_LIMIT, settings.RATE_LIMIT_LOGIN_WINDOW) + if any(path.startswith(prefix) for prefix in self.PASSWORD_RESET_PATHS): + return ('password_reset', settings.RATE_LIMIT_PASSWORD_RESET_LIMIT, settings.RATE_LIMIT_PASSWORD_RESET_WINDOW) + if any(path.startswith(prefix) for prefix in self.ADMIN_SENSITIVE_PREFIXES): + return ('admin_post', settings.RATE_LIMIT_ADMIN_ACTION_LIMIT, settings.RATE_LIMIT_ADMIN_ACTION_WINDOW) + return None + + def __call__(self, request): + if not settings.RATE_LIMIT_ENABLED or request.method != 'POST': + return self.get_response(request) + + rule = self._match_rule(request.path or '/') + if not rule: + return self.get_response(request) + + scope, limit, window = rule + identifier = self._client_identifier(request) + cache_key = f'ratelimit:{scope}:{identifier}' + added = cache.add(cache_key, 1, timeout=window) + current = 1 if added else cache.incr(cache_key) + if current > limit: + response = HttpResponse( + _('Zu viele Anfragen. Bitte versuchen Sie es in wenigen Minuten erneut.'), + status=429, + content_type='text/plain; charset=utf-8', + ) + retry_after = cache.ttl(cache_key) if hasattr(cache, 'ttl') else window + response['Retry-After'] = str(max(1, retry_after)) + return response + + return self.get_response(request) + + +class AuthSessionHardeningMiddleware: + EXEMPT_PREFIXES = ( + '/healthz/', + '/i18n/', + '/accounts/login/', + '/accounts/logout/', + '/accounts/password_reset/', + '/accounts/reset/', + '/static/', + '/media/', + ) + SENSITIVE_POST_PREFIXES = ( + '/admin-tools/users/', + '/admin-tools/backups/', + '/admin-tools/trial/save/', + '/admin-tools/apps/save/', + '/admin-tools/branding/save/', + '/admin-tools/company/save/', + '/admin-tools/integrations/save', + '/requests/delete/', + ) + + def __init__(self, get_response): + self.get_response = get_response + + def _is_exempt(self, path: str) -> bool: + return any(path.startswith(prefix) for prefix in self.EXEMPT_PREFIXES) + + def _touch_session(self, request, now_ts: int) -> None: + request.session['last_activity_ts'] = now_ts + request.session.setdefault('auth_fresh_ts', now_ts) + + def _warn(self, request, message: str) -> None: + try: + messages.warning(request, message) + except MessageFailure: + return + + def __call__(self, request): + path = request.path or '/' + user = getattr(request, 'user', None) + if not getattr(user, 'is_authenticated', False) or self._is_exempt(path): + return self.get_response(request) + + now_ts = int(timezone.now().timestamp()) + idle_timeout = max(60, settings.SESSION_IDLE_TIMEOUT_SECONDS) + last_activity_ts = int(request.session.get('last_activity_ts') or now_ts) + if now_ts - last_activity_ts > idle_timeout: + logout(request) + self._warn(request, _('Ihre Sitzung ist wegen Inaktivität abgelaufen. Bitte melden Sie sich erneut an.')) + login_url = reverse('login') + return redirect(f'{login_url}?next={request.get_full_path()}') + + if request.method == 'POST' and any(path.startswith(prefix) for prefix in self.SENSITIVE_POST_PREFIXES): + fresh_window = max(60, settings.SENSITIVE_ACTION_REAUTH_SECONDS) + auth_fresh_ts = int(request.session.get('auth_fresh_ts') or last_activity_ts) + if now_ts - auth_fresh_ts > fresh_window: + logout(request) + self._warn(request, _('Bitte bestätigen Sie Ihre Identität erneut, bevor Sie diese sensible Aktion ausführen.')) + login_url = reverse('login') + return redirect(f'{login_url}?next={request.get_full_path()}') + + response = self.get_response(request) + self._touch_session(request, now_ts) + return response + + class TrialModeMiddleware: EXEMPT_PREFIXES = ( '/healthz/', diff --git a/backend/workflows/migrations/0046_alter_onboardingrequest_phone_number.py b/backend/workflows/migrations/0046_alter_onboardingrequest_phone_number.py new file mode 100644 index 0000000..d00ea57 --- /dev/null +++ b/backend/workflows/migrations/0046_alter_onboardingrequest_phone_number.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2026-03-26 23:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0045_alter_portalbranding_company_domain_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='onboardingrequest', + name='phone_number', + field=models.CharField(blank=True, max_length=100, verbose_name='Telefon-Direktwahl'), + ), + ] diff --git a/backend/workflows/models.py b/backend/workflows/models.py index 0133958..92a8a4d 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -272,7 +272,7 @@ class OnboardingRequest(models.Model): additional_access_needed = models.BooleanField(default=False, verbose_name='Werden weitere Zugänge benötigt?') additional_access_text = models.TextField(blank=True, verbose_name='Weitere Zugänge (Freitext)') needed_resources = models.TextField(blank=True, verbose_name='Benötigte Ressourcen') - phone_number = models.CharField(max_length=100, blank=True, verbose_name='TUB/CO-Telefon-Direktwahl-Nr. 030 447202 (10-89)') + phone_number = models.CharField(max_length=100, blank=True, verbose_name='Telefon-Direktwahl') successor_required = models.BooleanField(default=False, verbose_name='Neue Mitarbeitende ist Nachfolge von?') successor_name = models.CharField(max_length=255, blank=True, verbose_name='Name der Vorgängerperson') inherit_phone_number = models.BooleanField(default=False, verbose_name='Telefonnummer von Vorgängerperson übernehmen') diff --git a/backend/workflows/roles.py b/backend/workflows/roles.py index 579d4cb..1c4d684 100644 --- a/backend/workflows/roles.py +++ b/backend/workflows/roles.py @@ -4,6 +4,10 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.utils.translation import gettext_lazy as _ +# Product-level and company-level roles intentionally coexist here. +# Workdock uses capability checks as the long-term contract so app-registry +# visibility can stay a presentation concern instead of an authorization layer. + ROLE_PLATFORM_OWNER = 'platform_owner' ROLE_SUPER_ADMIN = 'super_admin' ROLE_ADMIN = 'admin' @@ -27,6 +31,7 @@ ROLE_LABELS = { } CAPABILITIES = { + # Platform-only capabilities stay above any customer-company admin role. 'manage_users': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN}, 'manage_product_branding': {ROLE_PLATFORM_OWNER}, 'manage_company_config': {ROLE_PLATFORM_OWNER}, @@ -94,6 +99,8 @@ def ensure_bootstrap_role_assignments() -> None: def get_user_role_key(user) -> str: + # Keep a conservative fallback for legacy staff users until a later + # dedicated cleanup phase removes the remaining historical assumptions. if not getattr(user, 'is_authenticated', False): return ROLE_STAFF if getattr(user, 'is_superuser', False): diff --git a/backend/workflows/static/workflows/css/app_chrome.css b/backend/workflows/static/workflows/css/app_chrome.css index 954f249..aba6be6 100644 --- a/backend/workflows/static/workflows/css/app_chrome.css +++ b/backend/workflows/static/workflows/css/app_chrome.css @@ -180,6 +180,143 @@ align-items: center; } +.app-user-menu { + position: relative; +} + +.app-user-trigger { + list-style: none; + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 6px 10px 6px 6px; + border: 1px solid var(--app-line); + border-radius: 999px; + background: rgba(248, 251, 255, 0.92); + color: #1f3a5f; + cursor: pointer; + transition: + border-color var(--motion-fast) var(--motion-ease), + background-color var(--motion-fast) var(--motion-ease), + transform var(--motion-fast) var(--motion-ease), + box-shadow var(--motion-fast) var(--motion-ease); +} + +.app-user-trigger::-webkit-details-marker { + display: none; +} + +.app-user-trigger:hover { + transform: translateY(-1px); +} + +.app-user-menu[open] .app-user-trigger { + border-color: rgba(0, 0, 120, 0.22); + box-shadow: 0 0 0 4px rgba(0, 0, 120, 0.08); +} + +.app-user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(180deg, var(--app-brand-blue), #1d3ca8); + color: #fff; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.app-user-copy { + display: grid; + gap: 1px; + text-align: left; +} + +.app-user-copy strong { + font-size: 13px; + line-height: 1.2; +} + +.app-user-copy span { + color: #64748b; + font-size: 11px; + line-height: 1.2; +} + +.app-user-caret { + color: #64748b; + font-size: 12px; +} + +.app-user-panel { + position: absolute; + top: calc(100% + 10px); + right: 0; + min-width: 220px; + padding: 10px; + border: 1px solid rgba(217, 227, 238, 0.96); + border-radius: 18px; + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 24px 44px rgba(18, 34, 56, 0.16); + display: grid; + gap: 4px; + z-index: 40; +} + +.app-user-panel-head { + display: grid; + gap: 2px; + padding: 4px 6px 8px; + margin-bottom: 4px; + border-bottom: 1px solid rgba(217, 227, 238, 0.85); +} + +.app-user-panel-head strong { + color: #132238; + font-size: 13px; + line-height: 1.2; +} + +.app-user-panel-head span { + color: #64748b; + font-size: 12px; + line-height: 1.4; +} + +.app-user-panel a, +.app-user-panel button { + width: 100%; + border: 0; + border-radius: 12px; + background: transparent; + color: #1f3a5f; + font: inherit; + font-size: 13px; + font-weight: 700; + text-align: left; + text-decoration: none; + padding: 10px 12px; + cursor: pointer; + transition: + background-color var(--motion-fast) var(--motion-ease), + color var(--motion-fast) var(--motion-ease); +} + +.app-user-panel a:hover, +.app-user-panel button:hover { + background: #f4f8ff; + color: var(--app-brand-blue); +} + +.app-user-panel form { + margin: 0; +} + .app-lang-switch { display: flex; gap: 6px; diff --git a/backend/workflows/static/workflows/css/login.css b/backend/workflows/static/workflows/css/login.css index 5efb371..2b3664a 100644 --- a/backend/workflows/static/workflows/css/login.css +++ b/backend/workflows/static/workflows/css/login.css @@ -47,6 +47,58 @@ body { line-height: 1.45; } +.account-card { + width: min(560px, 100%); +} + +.account-grid { + display: grid; + gap: 10px; + margin: 0 0 16px; +} + +.account-row { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: baseline; + padding: 10px 12px; + border: 1px solid #d9e3f0; + border-radius: 12px; + background: #f9fbff; +} + +.account-row span { + color: #607086; + font-size: 13px; +} + +.account-row strong { + color: #132238; + font-size: 14px; + text-align: right; +} + +.account-actions { + display: flex; + gap: 10px; +} + +.account-actions .btn { + width: auto; +} + +.account-actions form { + margin: 0; +} + +.hint { + margin-top: 6px; + color: #8a5a00; + font-size: 12px; + line-height: 1.4; +} + .field { margin-bottom: 12px; } @@ -114,4 +166,21 @@ body { padding: 18px; border-radius: 16px; } + + .account-row { + flex-direction: column; + align-items: flex-start; + } + + .account-row strong { + text-align: left; + } + + .account-actions { + flex-direction: column; + } + + .account-actions .btn { + width: 100%; + } } diff --git a/backend/workflows/tasks.py b/backend/workflows/tasks.py index e45e594..950e4eb 100644 --- a/backend/workflows/tasks.py +++ b/backend/workflows/tasks.py @@ -29,6 +29,9 @@ from .forms import ( WORKSPACE_GROUP_CHOICES, ) +# These templates are the product-level defaults for fresh deployments. +# Runtime branding and company config can override the company-facing identity +# without changing the workflow/task logic itself. DEFAULT_NOTIFICATION_TEMPLATES = { 'onboarding_it': { @@ -158,27 +161,27 @@ DEFAULT_NOTIFICATION_TEMPLATES = { ), }, 'onboarding_welcome': { - 'subject': 'Willkommen bei TUB/CO, {{ VORNAME }}', - 'subject_en': 'Welcome to TUB/CO, {{ VORNAME }}', + 'subject': 'Willkommen bei {{ COMPANY_NAME }}, {{ VORNAME }}', + 'subject_en': 'Welcome to {{ COMPANY_NAME }}, {{ VORNAME }}', 'body': ( 'Hallo {{ FULL_NAME }},\n\n' - 'herzlich willkommen bei TUB/CO.\n' + 'herzlich willkommen bei {{ COMPANY_NAME }}.\n' 'Wir freuen uns sehr, dass du ab dem {{ CONTRACT_START }} unser Team in der Abteilung {{ DEPARTMENT }} verstärkst.\n\n' 'Deine dienstliche E-Mail-Adresse lautet: {{ EMAIL }}.\n' 'Im Anhang findest du deine Onboarding-Unterlagen als PDF.\n\n' 'Wenn du Fragen hast, melde dich gerne jederzeit.\n\n' 'Viele Grüße\n' - 'TUB/CO IT' + '{{ COMPANY_NAME }} IT' ), 'body_en': ( 'Hello {{ FULL_NAME }},\n\n' - 'welcome to TUB/CO.\n' + 'welcome to {{ COMPANY_NAME }}.\n' 'We are very happy that you will join our {{ DEPARTMENT }} team starting on {{ CONTRACT_START }}.\n\n' 'Your work email address is: {{ EMAIL }}.\n' 'You will find your onboarding documents attached as a PDF.\n\n' 'If you have any questions, feel free to contact us anytime.\n\n' 'Best regards,\n' - 'TUB/CO IT' + '{{ COMPANY_NAME }} IT' ), }, 'offboarding_it': { diff --git a/backend/workflows/templates/workflows/account_profile.html b/backend/workflows/templates/workflows/account_profile.html new file mode 100644 index 0000000..f5dacb1 --- /dev/null +++ b/backend/workflows/templates/workflows/account_profile.html @@ -0,0 +1,52 @@ +{% extends 'workflows/base_shell.html' %} +{% load static i18n %} + +{% block title %}{% trans "Profil" %}{% endblock %} + +{% block shell_header %} +{% include 'workflows/includes/app_header.html' with header_show_home=1 header_show_lang=1 header_inside_shell=1 %} +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block shell_body %} +
+
+

{% trans "Profil" %}

+

{% trans "Ihre aktuelle Workdock-Kontoübersicht und direkte Kontoaktionen." %}

+ + + + +
+
+{% endblock %} diff --git a/backend/workflows/templates/workflows/auth/password_change_done.html b/backend/workflows/templates/workflows/auth/password_change_done.html new file mode 100644 index 0000000..ce900ed --- /dev/null +++ b/backend/workflows/templates/workflows/auth/password_change_done.html @@ -0,0 +1,22 @@ +{% extends 'workflows/base_shell.html' %} +{% load static i18n %} + +{% block title %}{% trans "Passwort geändert" %}{% endblock %} + +{% block shell_header %} +{% include 'workflows/includes/app_header.html' with header_show_lang=1 header_inside_shell=1 %} +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block shell_body %} +
+
+

{% trans "Passwort geändert" %}

+

{% trans "Ihr Passwort wurde erfolgreich aktualisiert." %}

+ {% trans "Zum Profil" %} +
+
+{% endblock %} diff --git a/backend/workflows/templates/workflows/auth/password_change_form.html b/backend/workflows/templates/workflows/auth/password_change_form.html new file mode 100644 index 0000000..d042d95 --- /dev/null +++ b/backend/workflows/templates/workflows/auth/password_change_form.html @@ -0,0 +1,37 @@ +{% extends 'workflows/base_shell.html' %} +{% load static i18n %} + +{% block title %}{% trans "Passwort ändern" %}{% endblock %} + +{% block shell_header %} +{% include 'workflows/includes/app_header.html' with header_show_lang=1 header_inside_shell=1 %} +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block shell_body %} +
+
+

{% trans "Passwort ändern" %}

+

{% trans "Vergeben Sie ein neues Passwort für Ihr Konto." %}

+
+ {% csrf_token %} +
+ {{ form.old_password.label_tag }}{{ form.old_password }} + {% for error in form.old_password.errors %}
{{ error }}
{% endfor %} +
+
+ {{ form.new_password1.label_tag }}{{ form.new_password1 }} + {% for error in form.new_password1.errors %}
{{ error }}
{% endfor %} +
+
+ {{ form.new_password2.label_tag }}{{ form.new_password2 }} + {% for error in form.new_password2.errors %}
{{ error }}
{% endfor %} +
+ +
+
+
+{% endblock %} diff --git a/backend/workflows/templates/workflows/developer_handbook.html b/backend/workflows/templates/workflows/developer_handbook.html index a4c0d93..ca7140b 100644 --- a/backend/workflows/templates/workflows/developer_handbook.html +++ b/backend/workflows/templates/workflows/developer_handbook.html @@ -177,7 +177,7 @@ docker compose exec -T web django-admin compilemessages
  • Portal-level branding is stored in the singleton model PortalBranding.
  • Configured from Admin Apps → Branding.
  • Current scope: portal title, company name, company domain, support email, sender display name, login subtitle, footer/legal text, logo, favicon, PDF letterhead, and primary/secondary colors.
  • -
  • Shared header/logo rendering now uses the branding context processor instead of hardcoded TUBCO asset references.
  • +
  • Shared header/logo rendering now uses the branding context processor instead of hardcoded customer-specific asset references.
  • The company domain now drives onboarding/offboarding email autofill and domain validation, so new customer deployments no longer require @tub.co code changes.
  • Outgoing system mail sender names are now branded through the same layer.
  • User invitation emails and welcome-template fallbacks also use the configured branding defaults.
  • diff --git a/backend/workflows/templates/workflows/home.html b/backend/workflows/templates/workflows/home.html index 7472c20..54d2753 100644 --- a/backend/workflows/templates/workflows/home.html +++ b/backend/workflows/templates/workflows/home.html @@ -10,23 +10,7 @@ {% endblock %} {% block shell_body %} -
    -
    - -
    -
    -
    - {% csrf_token %} - - - -
    -
    - {% csrf_token %} - -
    -
    -
    +{% include 'workflows/includes/app_header.html' with header_show_lang=1 %}
    diff --git a/backend/workflows/templates/workflows/includes/app_header.html b/backend/workflows/templates/workflows/includes/app_header.html index 9bd189e..6f57087 100644 --- a/backend/workflows/templates/workflows/includes/app_header.html +++ b/backend/workflows/templates/workflows/includes/app_header.html @@ -19,5 +19,35 @@ {% if header_show_home %} {% trans "Zur Startseite" %} {% endif %} + {% if request.user.is_authenticated %} +
    + + + + {{ request.user.get_full_name|default:request.user.username }} + {{ role_label }} + + + +
    +
    + {{ request.user.get_full_name|default:request.user.username }} + {{ request.user.email|default:request.user.username }} +
    + {% trans "Profil" %} + {% trans "Passwort ändern" %} +
    + {% csrf_token %} + +
    +
    +
    + {% endif %}
    diff --git a/backend/workflows/tests/test_account_ui.py b/backend/workflows/tests/test_account_ui.py new file mode 100644 index 0000000..d9505c1 --- /dev/null +++ b/backend/workflows/tests/test_account_ui.py @@ -0,0 +1,26 @@ +from django.contrib.auth import get_user_model +from django.test import Client, TestCase + + +class AccountUISmokeTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + username='profile-user', + email='profile@example.com', + password='secret-12345', + first_name='Profile', + last_name='User', + ) + self.client = Client() + self.client.force_login(self.user) + + def test_account_profile_page_renders(self): + response = self.client.get('/account/', HTTP_HOST='localhost') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'profile@example.com') + self.assertContains(response, 'Passwort ändern') + + def test_password_change_page_renders(self): + response = self.client.get('/accounts/password_change/', HTTP_HOST='localhost') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Aktuelles Passwort') diff --git a/backend/workflows/tests/test_onboarding_flow.py b/backend/workflows/tests/test_onboarding_flow.py index dd2a34d..2e0ce44 100644 --- a/backend/workflows/tests/test_onboarding_flow.py +++ b/backend/workflows/tests/test_onboarding_flow.py @@ -12,7 +12,7 @@ class OnboardingFlowTests(TestCase): self.user = user_model.objects.create_user( username='onboard_user', password='secret123', - email='requester@tub.co', + email='requester@workdock.de', first_name='Mia', last_name='Beispiel', ) @@ -26,7 +26,7 @@ class OnboardingFlowTests(TestCase): 'gender': 'herr', 'job_title': 'Consultant', 'department': 'IT-Service', - 'work_email': 'max.mustermann@tub.co', + 'work_email': 'max.mustermann@workdock.de', 'contract_start': '2026-11-01', 'employment_type': 'unbefristet', 'group_mailboxes_required_choice': 'nein', @@ -43,8 +43,8 @@ class OnboardingFlowTests(TestCase): self.assertEqual(response.status_code, 302) self.assertIn('/onboarding/new/?saved=1&id=', response['Location']) - obj = OnboardingRequest.objects.get(work_email='max.mustermann@tub.co') + obj = OnboardingRequest.objects.get(work_email='max.mustermann@workdock.de') self.assertEqual(obj.full_name, 'Max Mustermann') - self.assertEqual(obj.onboarded_by_email, 'requester@tub.co') + self.assertEqual(obj.onboarded_by_email, 'requester@workdock.de') self.assertEqual(obj.onboarded_by_name, 'Mia Beispiel') mock_delay.assert_called_once_with(obj.id) diff --git a/backend/workflows/tests/test_security_hardening.py b/backend/workflows/tests/test_security_hardening.py new file mode 100644 index 0000000..7d114e4 --- /dev/null +++ b/backend/workflows/tests/test_security_hardening.py @@ -0,0 +1,96 @@ +from django.contrib.auth import get_user_model +from django.test import Client, TestCase, override_settings + +from workflows.checks import security_settings_check + + +@override_settings(DEBUG=True) +class RateLimitMiddlewareTests(TestCase): + @override_settings(RATE_LIMIT_LOGIN_LIMIT=2, RATE_LIMIT_LOGIN_WINDOW=60) + def test_login_is_rate_limited(self): + client = Client(REMOTE_ADDR='10.10.10.10') + for _ in range(2): + response = client.post('/accounts/login/', {'username': 'x', 'password': 'y'}, HTTP_HOST='localhost') + self.assertNotEqual(response.status_code, 429) + + response = client.post('/accounts/login/', {'username': 'x', 'password': 'y'}, HTTP_HOST='localhost') + self.assertEqual(response.status_code, 429) + self.assertIn('Retry-After', response) + + @override_settings(RATE_LIMIT_PASSWORD_RESET_LIMIT=1, RATE_LIMIT_PASSWORD_RESET_WINDOW=60) + def test_password_reset_is_rate_limited(self): + client = Client(REMOTE_ADDR='10.10.10.20') + response = client.post('/accounts/password_reset/', {'email': 'nobody@example.com'}, HTTP_HOST='localhost') + self.assertNotEqual(response.status_code, 429) + + response = client.post('/accounts/password_reset/', {'email': 'nobody@example.com'}, HTTP_HOST='localhost') + self.assertEqual(response.status_code, 429) + + @override_settings(RATE_LIMIT_ADMIN_ACTION_LIMIT=1, RATE_LIMIT_ADMIN_ACTION_WINDOW=60) + def test_sensitive_admin_posts_are_rate_limited(self): + client = Client(REMOTE_ADDR='10.10.10.30') + response = client.post('/admin-tools/branding/save/', {'portal_title': 'A'}, HTTP_HOST='localhost') + self.assertNotEqual(response.status_code, 429) + + response = client.post('/admin-tools/branding/save/', {'portal_title': 'B'}, HTTP_HOST='localhost') + self.assertEqual(response.status_code, 429) + + @override_settings(RATE_LIMIT_LOGIN_LIMIT=1, RATE_LIMIT_LOGIN_WINDOW=60) + def test_get_requests_are_not_rate_limited(self): + client = Client(REMOTE_ADDR='10.10.10.40') + first = client.get('/accounts/login/', HTTP_HOST='localhost') + second = client.get('/accounts/login/', HTTP_HOST='localhost') + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + + +class SecurityChecksTests(TestCase): + @override_settings( + DEBUG=False, + SECRET_KEY='unsafe-dev-key', + ALLOWED_HOSTS=[], + SESSION_COOKIE_SECURE=False, + CSRF_COOKIE_SECURE=False, + SECURE_SSL_REDIRECT=False, + RUN_SECURITY_CHECKS_DURING_TESTS=True, + ) + def test_security_checks_report_production_issues(self): + issues = security_settings_check(None) + ids = {issue.id for issue in issues} + self.assertIn('workdock.E001', ids) + self.assertIn('workdock.E002', ids) + self.assertIn('workdock.E003', ids) + self.assertIn('workdock.E004', ids) + self.assertIn('workdock.W001', ids) + + +@override_settings(DEBUG=True) +class AuthSessionHardeningTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user(username='security-user', password='secret-12345') + + @override_settings(SESSION_IDLE_TIMEOUT_SECONDS=60) + def test_idle_session_forces_relogin(self): + client = Client(REMOTE_ADDR='10.10.10.50') + client.force_login(self.user) + session = client.session + session['last_activity_ts'] = 1 + session['auth_fresh_ts'] = 1 + session.save() + + response = client.get('/', HTTP_HOST='localhost') + self.assertEqual(response.status_code, 302) + self.assertIn('/accounts/login/', response['Location']) + + @override_settings(SENSITIVE_ACTION_REAUTH_SECONDS=60) + def test_stale_sensitive_post_forces_relogin(self): + client = Client(REMOTE_ADDR='10.10.10.60') + client.force_login(self.user) + session = client.session + session['last_activity_ts'] = 9999999999 + session['auth_fresh_ts'] = 1 + session.save() + + response = client.post('/admin-tools/branding/save/', {'portal_title': 'Blocked'}, HTTP_HOST='localhost') + self.assertEqual(response.status_code, 302) + self.assertIn('/accounts/login/', response['Location']) diff --git a/backend/workflows/trial.py b/backend/workflows/trial.py index 6b2e4df..dd438bf 100644 --- a/backend/workflows/trial.py +++ b/backend/workflows/trial.py @@ -12,6 +12,10 @@ from .branding import get_portal_trial_config, is_trial_expired from .models import AdminAuditLog, EmployeeProfile, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail from .roles import ROLE_PLATFORM_OWNER, get_user_role_key +# Trial cleanup is intentionally destructive but platform-scoped. +# It preserves the platform-owner account so expired demo environments remain +# recoverable and inspectable by the product operator. + def cleanup_trial_workspace_data() -> dict[str, int]: user_model = get_user_model() diff --git a/backend/workflows/urls.py b/backend/workflows/urls.py index 13265e0..0a3a82d 100644 --- a/backend/workflows/urls.py +++ b/backend/workflows/urls.py @@ -5,6 +5,7 @@ from . import views urlpatterns = [ path('healthz/', views.healthz, name='healthz'), path('', views.home, name='home'), + path('account/', views.account_profile_page, name='account_profile_page'), path('requests/', views.requests_dashboard, name='requests_dashboard'), path('onboarding/new/', views.onboarding_create, name='onboarding_create'), path('onboarding/success//', views.onboarding_success, name='onboarding_success'), diff --git a/backend/workflows/views.py b/backend/workflows/views.py index afbacb0..a778cd1 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -118,7 +118,7 @@ def healthz(request): return JsonResponse( { 'status': 'ok' if db_ok else 'degraded', - 'service': 'onoff_v2', + 'service': 'workdock', 'db': 'ok' if db_ok else 'error', 'time': timezone.now().isoformat(), }, @@ -126,6 +126,18 @@ def healthz(request): ) +@login_required +def account_profile_page(request): + return render( + request, + 'workflows/account_profile.html', + { + 'account_user': request.user, + 'role_label': get_user_role_label(request.user), + }, + ) + + def _require_capability(capability: str): def decorator(view_func): @wraps(view_func)