snapshot: preserve session hardening and account surface

This commit is contained in:
Md Bayazid Bostame
2026-03-27 01:11:29 +01:00
parent bbc9b7b646
commit 8d228723f9
29 changed files with 825 additions and 42 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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')),
}

View File

@@ -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'),

View File

@@ -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,

View File

@@ -7,3 +7,4 @@ class WorkflowsConfig(AppConfig):
def ready(self):
from . import signals # noqa: F401
from . import checks # noqa: F401

View File

@@ -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'

View File

@@ -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')

View 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

View File

@@ -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'}),
)

View File

@@ -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/',

View File

@@ -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'),
),
]

View File

@@ -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')

View File

@@ -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):

View File

@@ -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;

View File

@@ -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%;
}
}

View File

@@ -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': {

View 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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View 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')

View File

@@ -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)

View 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'])

View File

@@ -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()

View File

@@ -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'),

View File

@@ -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)