snapshot: preserve session hardening and account surface
This commit is contained in:
17
.env.example
17
.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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')),
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,3 +7,4 @@ class WorkflowsConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
from . import signals # noqa: F401
|
||||
from . import checks # noqa: F401
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
57
backend/workflows/checks.py
Normal file
57
backend/workflows/checks.py
Normal file
@@ -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
|
||||
@@ -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'}),
|
||||
)
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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': {
|
||||
|
||||
52
backend/workflows/templates/workflows/account_profile.html
Normal file
52
backend/workflows/templates/workflows/account_profile.html
Normal file
@@ -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 %}
|
||||
<link rel="stylesheet" href="{% static 'workflows/css/login.css' %}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block shell_body %}
|
||||
<section class="login-shell-body">
|
||||
<div class="login-card account-card">
|
||||
<h1>{% trans "Profil" %}</h1>
|
||||
<p>{% trans "Ihre aktuelle Workdock-Kontoübersicht und direkte Kontoaktionen." %}</p>
|
||||
|
||||
<div class="account-grid">
|
||||
<div class="account-row">
|
||||
<span>{% trans "Name" %}</span>
|
||||
<strong>{{ account_user.get_full_name|default:account_user.username }}</strong>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>{% trans "Benutzername" %}</span>
|
||||
<strong>{{ account_user.username }}</strong>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>{% trans "E-Mail" %}</span>
|
||||
<strong>{{ account_user.email|default:"-" }}</strong>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>{% trans "Rolle" %}</span>
|
||||
<strong>{{ role_label }}</strong>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>{% trans "Letzte Anmeldung" %}</span>
|
||||
<strong>{% if account_user.last_login %}{{ account_user.last_login|date:"d.m.Y H:i" }}{% else %}-{% endif %}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="account-actions">
|
||||
<a class="btn btn-primary" href="{% url 'password_change' %}">{% trans "Passwort ändern" %}</a>
|
||||
<form method="post" action="{% url 'logout' %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-secondary" type="submit">{% trans "Abmelden" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
<link rel="stylesheet" href="{% static 'workflows/css/login.css' %}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block shell_body %}
|
||||
<section class="login-shell-body">
|
||||
<div class="login-card">
|
||||
<h1>{% trans "Passwort geändert" %}</h1>
|
||||
<p>{% trans "Ihr Passwort wurde erfolgreich aktualisiert." %}</p>
|
||||
<a class="btn btn-primary" href="{% url 'account_profile_page' %}">{% trans "Zum Profil" %}</a>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
<link rel="stylesheet" href="{% static 'workflows/css/login.css' %}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block shell_body %}
|
||||
<section class="login-shell-body">
|
||||
<div class="login-card">
|
||||
<h1>{% trans "Passwort ändern" %}</h1>
|
||||
<p>{% trans "Vergeben Sie ein neues Passwort für Ihr Konto." %}</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="field{% if form.old_password.errors %} has-error{% endif %}">
|
||||
{{ form.old_password.label_tag }}{{ form.old_password }}
|
||||
{% for error in form.old_password.errors %}<div class="hint">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="field{% if form.new_password1.errors %} has-error{% endif %}">
|
||||
{{ form.new_password1.label_tag }}{{ form.new_password1 }}
|
||||
{% for error in form.new_password1.errors %}<div class="hint">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="field{% if form.new_password2.errors %} has-error{% endif %}">
|
||||
{{ form.new_password2.label_tag }}{{ form.new_password2 }}
|
||||
{% for error in form.new_password2.errors %}<div class="hint">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">{% trans "Passwort speichern" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -177,7 +177,7 @@ docker compose exec -T web django-admin compilemessages</code></pre>
|
||||
<li>Portal-level branding is stored in the singleton model <code>PortalBranding</code>.</li>
|
||||
<li>Configured from Admin Apps → <code>Branding</code>.</li>
|
||||
<li>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.</li>
|
||||
<li>Shared header/logo rendering now uses the branding context processor instead of hardcoded TUBCO asset references.</li>
|
||||
<li>Shared header/logo rendering now uses the branding context processor instead of hardcoded customer-specific asset references.</li>
|
||||
<li>The company domain now drives onboarding/offboarding email autofill and domain validation, so new customer deployments no longer require <code>@tub.co</code> code changes.</li>
|
||||
<li>Outgoing system mail sender names are now branded through the same layer.</li>
|
||||
<li>User invitation emails and welcome-template fallbacks also use the configured branding defaults.</li>
|
||||
|
||||
@@ -10,23 +10,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block shell_body %}
|
||||
<div class="topbar">
|
||||
<div class="brand-wrap">
|
||||
<a class="app-brand" href="/"><img class="brand-logo" src="{{ portal_logo_url }}" alt="{{ portal_company_name }} Logo" /></a>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<form method="post" action="{% url 'set_language' %}" class="lang-switch">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="next" value="{{ request.get_full_path }}" />
|
||||
<button class="lang-btn {% if CURRENT_LANGUAGE == 'de' %}active{% endif %}" type="submit" name="language" value="de">DE</button>
|
||||
<button class="lang-btn {% if CURRENT_LANGUAGE == 'en' %}active{% endif %}" type="submit" name="language" value="en">EN</button>
|
||||
</form>
|
||||
<form method="post" action="/accounts/logout/" style="display:inline;">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-secondary" type="submit">{% trans "Abmelden" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'workflows/includes/app_header.html' with header_show_lang=1 %}
|
||||
|
||||
<div class="hero">
|
||||
<div class="hero-grid">
|
||||
|
||||
@@ -19,5 +19,35 @@
|
||||
{% if header_show_home %}
|
||||
<a class="btn btn-secondary" href="/">{% trans "Zur Startseite" %}</a>
|
||||
{% endif %}
|
||||
{% if request.user.is_authenticated %}
|
||||
<details class="app-user-menu">
|
||||
<summary class="app-user-trigger">
|
||||
<span class="app-user-avatar" aria-hidden="true">
|
||||
{% if request.user.first_name or request.user.last_name %}
|
||||
{{ request.user.first_name|slice:":1" }}{{ request.user.last_name|slice:":1" }}
|
||||
{% else %}
|
||||
{{ request.user.username|slice:":2" }}
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="app-user-copy">
|
||||
<strong>{{ request.user.get_full_name|default:request.user.username }}</strong>
|
||||
<span>{{ role_label }}</span>
|
||||
</span>
|
||||
<span class="app-user-caret" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="app-user-panel">
|
||||
<div class="app-user-panel-head">
|
||||
<strong>{{ request.user.get_full_name|default:request.user.username }}</strong>
|
||||
<span>{{ request.user.email|default:request.user.username }}</span>
|
||||
</div>
|
||||
<a href="{% url 'account_profile_page' %}">{% trans "Profil" %}</a>
|
||||
<a href="{% url 'password_change' %}">{% trans "Passwort ändern" %}</a>
|
||||
<form method="post" action="{% url 'logout' %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit">{% trans "Abmelden" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
26
backend/workflows/tests/test_account_ui.py
Normal file
26
backend/workflows/tests/test_account_ui.py
Normal file
@@ -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')
|
||||
@@ -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)
|
||||
|
||||
96
backend/workflows/tests/test_security_hardening.py
Normal file
96
backend/workflows/tests/test_security_hardening.py
Normal file
@@ -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'])
|
||||
@@ -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()
|
||||
|
||||
@@ -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/<int:request_id>/', views.onboarding_success, name='onboarding_success'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user