snapshot: preserve account security and profile UI cleanup

This commit is contained in:
Md Bayazid Bostame
2026-03-27 03:04:02 +01:00
parent c679488437
commit f2c9b3b65d
12 changed files with 699 additions and 370 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -10,3 +10,4 @@ pypdf==5.1.0
jinja2==3.1.4 jinja2==3.1.4
xhtml2pdf==0.2.16 xhtml2pdf==0.2.16
gunicorn==23.0.0 gunicorn==23.0.0
qrcode==8.2

View File

@@ -11,7 +11,7 @@ from .branding import get_company_email_domain
from .form_builder import apply_form_field_config from .form_builder import apply_form_field_config
from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, PortalTrialConfig, UserProfile, WorkflowConfig from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, PortalTrialConfig, UserProfile, WorkflowConfig
from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role
from .totp import normalize_totp_token, verify_totp_token from .totp import normalize_recovery_code, normalize_totp_token, verify_totp_token
YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')] YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')]
@@ -111,6 +111,12 @@ class AppAuthenticationForm(AuthenticationForm):
max_length=12, max_length=12,
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}), widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}),
) )
recovery_code = forms.CharField(
label=gettext_lazy('Recovery-Code'),
required=False,
max_length=32,
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code'}),
)
error_messages = { error_messages = {
**AuthenticationForm.error_messages, **AuthenticationForm.error_messages,
@@ -126,6 +132,14 @@ class AppAuthenticationForm(AuthenticationForm):
profile, _ = UserProfile.objects.get_or_create(user=user) profile, _ = UserProfile.objects.get_or_create(user=user)
if profile.totp_enabled: if profile.totp_enabled:
otp_code = normalize_totp_token(cleaned_data.get('otp_code')) otp_code = normalize_totp_token(cleaned_data.get('otp_code'))
recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code'))
if recovery_code:
if not profile.consume_recovery_code(recovery_code):
raise ValidationError(
self.error_messages['invalid_otp'],
code='invalid_otp',
)
return cleaned_data
if not otp_code: if not otp_code:
raise ValidationError( raise ValidationError(
self.error_messages['missing_otp'], self.error_messages['missing_otp'],
@@ -296,8 +310,15 @@ class AccountTOTPDisableForm(forms.Form):
verification_code = forms.CharField( verification_code = forms.CharField(
label=gettext_lazy('TOTP-Code'), label=gettext_lazy('TOTP-Code'),
max_length=12, max_length=12,
required=False,
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}), widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}),
) )
recovery_code = forms.CharField(
label=gettext_lazy('Recovery-Code'),
max_length=32,
required=False,
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code'}),
)
def __init__(self, *args, user=None, profile=None, **kwargs): def __init__(self, *args, user=None, profile=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -310,14 +331,66 @@ class AccountTOTPDisableForm(forms.Form):
raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.')) raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.'))
return password return password
def clean_verification_code(self): def clean(self):
code = normalize_totp_token(self.cleaned_data.get('verification_code')) cleaned_data = super().clean()
if not code: code = normalize_totp_token(cleaned_data.get('verification_code'))
raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code ein.')) recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code'))
if not code and not recovery_code:
raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code oder Recovery-Code ein.'))
secret = getattr(self.profile, 'totp_secret', '') or '' secret = getattr(self.profile, 'totp_secret', '') or ''
if not secret or not verify_totp_token(secret, code, for_time=int(timezone.now().timestamp())): if code:
raise ValidationError(_('Der TOTP-Code ist ungültig.')) if not secret or not verify_totp_token(secret, code, for_time=int(timezone.now().timestamp())):
return code raise ValidationError(_('Der TOTP-Code ist ungültig.'))
return cleaned_data
if not self.profile.consume_recovery_code(recovery_code):
raise ValidationError(_('Der Recovery-Code ist ungültig.'))
return cleaned_data
class AccountTOTPRegenerateRecoveryCodesForm(forms.Form):
current_password = forms.CharField(
label=gettext_lazy('Aktuelles Passwort'),
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
)
verification_code = forms.CharField(
label=gettext_lazy('TOTP-Code'),
max_length=12,
required=False,
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}),
)
recovery_code = forms.CharField(
label=gettext_lazy('Recovery-Code'),
max_length=32,
required=False,
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code'}),
)
def __init__(self, *args, user=None, profile=None, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
self.profile = profile
def clean_current_password(self):
password = self.cleaned_data.get('current_password') or ''
if not self.user or not self.user.check_password(password):
raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.'))
return password
def clean(self):
cleaned_data = super().clean()
code = normalize_totp_token(cleaned_data.get('verification_code'))
recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code'))
if not code and not recovery_code:
raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code oder Recovery-Code ein.'))
secret = getattr(self.profile, 'totp_secret', '') or ''
if code:
if not secret or not verify_totp_token(secret, code, for_time=int(timezone.now().timestamp())):
raise ValidationError(_('Der TOTP-Code ist ungültig.'))
return cleaned_data
if not self.profile.consume_recovery_code(recovery_code):
raise ValidationError(_('Der Recovery-Code ist ungültig.'))
return cleaned_data
class UserManagementCreateForm(forms.Form): class UserManagementCreateForm(forms.Form):

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0049_userprofile_totp_fields'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='totp_recovery_codes',
field=models.JSONField(blank=True, default=list),
),
]

View File

@@ -1,4 +1,5 @@
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import check_password, make_password
from django.core.validators import FileExtensionValidator from django.core.validators import FileExtensionValidator
from django.db import models from django.db import models
from django.utils.translation import get_language from django.utils.translation import get_language
@@ -43,6 +44,7 @@ class UserProfile(models.Model):
totp_secret = models.CharField(max_length=64, blank=True, default='') totp_secret = models.CharField(max_length=64, blank=True, default='')
totp_enabled = models.BooleanField(default=False) totp_enabled = models.BooleanField(default=False)
totp_confirmed_at = models.DateTimeField(null=True, blank=True) totp_confirmed_at = models.DateTimeField(null=True, blank=True)
totp_recovery_codes = models.JSONField(default=list, blank=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
@@ -56,13 +58,31 @@ class UserProfile(models.Model):
self.totp_secret = '' self.totp_secret = ''
self.totp_enabled = False self.totp_enabled = False
self.totp_confirmed_at = None self.totp_confirmed_at = None
self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'updated_at']) self.totp_recovery_codes = []
self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'totp_recovery_codes', 'updated_at'])
def enable_totp(self, secret: str) -> None: def enable_totp(self, secret: str, recovery_codes: list[str]) -> None:
self.totp_secret = secret self.totp_secret = secret
self.totp_enabled = True self.totp_enabled = True
self.totp_confirmed_at = timezone.now() self.totp_confirmed_at = timezone.now()
self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'updated_at']) self.set_recovery_codes(recovery_codes)
self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'totp_recovery_codes', 'updated_at'])
def set_recovery_codes(self, recovery_codes: list[str]) -> None:
self.totp_recovery_codes = [make_password(code) for code in recovery_codes]
def consume_recovery_code(self, raw_code: str) -> bool:
remaining_hashes = []
matched = False
for hashed_code in self.totp_recovery_codes or []:
if not matched and check_password(raw_code, hashed_code):
matched = True
continue
remaining_hashes.append(hashed_code)
if matched:
self.totp_recovery_codes = remaining_hashes
self.save(update_fields=['totp_recovery_codes', 'updated_at'])
return matched
class PortalBranding(models.Model): class PortalBranding(models.Model):

View File

@@ -291,13 +291,46 @@ body {
word-break: break-word; word-break: break-word;
} }
.account-action-grid { .account-security-overview {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px; gap: 14px;
margin-bottom: 18px; margin-bottom: 18px;
} }
.account-security-item {
padding: 16px 18px;
border-radius: 18px;
border: 1px solid #dbe5f2;
background:
radial-gradient(circle at top right, rgba(30, 64, 175, 0.06), transparent 26%),
linear-gradient(180deg, rgba(255,255,255,0.96), rgba(246,250,255,0.9));
}
.account-security-item span {
display: block;
margin-bottom: 6px;
color: #6b7a90;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.account-security-item strong {
display: block;
color: #132238;
font-size: 20px;
line-height: 1.2;
}
.account-security-item p {
margin: 8px 0 0;
color: #617389;
font-size: 13px;
line-height: 1.5;
}
.account-totp-card { .account-totp-card {
margin-bottom: 18px; margin-bottom: 18px;
padding: 18px; padding: 18px;
@@ -326,56 +359,77 @@ body {
margin-top: 14px; margin-top: 14px;
} }
.account-action-card { .account-qr-card,
display: grid; .account-recovery-card {
gap: 6px; margin-top: 14px;
padding: 16px 18px; padding: 16px;
border-radius: 18px; border-radius: 18px;
border: 1px solid #dbe5f2; border: 1px solid #dbe5f2;
background: background: rgba(255, 255, 255, 0.82);
radial-gradient(circle at top right, rgba(30, 64, 175, 0.08), transparent 28%),
#f9fbff;
color: inherit;
text-decoration: none;
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
} }
.account-action-card:hover { .account-qr-card svg {
transform: translateY(-1px); display: block;
box-shadow: 0 10px 24px rgba(28, 45, 79, 0.08); width: min(220px, 100%);
height: auto;
margin: 0 auto;
} }
.account-action-card strong { .account-secret-panel {
color: #132238; margin-top: 14px;
font-size: 15px; padding-top: 14px;
border-top: 1px solid #dbe5f2;
} }
.account-action-card span { .account-secret-head {
color: #617389;
font-size: 13px;
line-height: 1.5;
}
.account-action-card-muted {
cursor: default;
}
.account-action-card-muted:hover {
transform: none;
box-shadow: none;
}
.account-actions {
display: flex; display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.account-secret-head span {
display: block;
margin-bottom: 4px;
color: #6b7a90;
font-size: 12px;
}
.account-secret-head strong {
color: #132238;
font-size: 14px;
}
.account-secret-toggle {
min-width: 48px;
padding-left: 0;
padding-right: 0;
}
.account-secret-body {
margin-top: 12px;
}
.account-secret-body.is-hidden {
display: none;
}
.account-recovery-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px; gap: 10px;
} }
.account-actions .btn { .account-recovery-code {
width: auto; padding: 12px 14px;
} border-radius: 14px;
border: 1px dashed #c8d7ea;
.account-actions form { background: #f7faff;
margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
color: #17345e;
} }
.account-inline-view.is-hidden, .account-inline-view.is-hidden,
@@ -491,23 +545,16 @@ body {
} }
.account-detail-grid, .account-detail-grid,
.account-action-grid, .account-security-overview,
.account-form-grid { .account-form-grid,
.account-recovery-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.account-actions {
flex-direction: column;
}
.account-inline-actions { .account-inline-actions {
flex-direction: column; flex-direction: column;
} }
.account-actions .btn {
width: 100%;
}
.account-panel-head { .account-panel-head {
flex-direction: column; flex-direction: column;
} }

View File

@@ -275,6 +275,7 @@
display: grid; display: grid;
gap: 4px; gap: 4px;
z-index: 40; z-index: 40;
overflow: hidden;
} }
.app-user-panel-head { .app-user-panel-head {
@@ -299,7 +300,10 @@
.app-user-panel a, .app-user-panel a,
.app-user-panel button { .app-user-panel button {
display: flex;
align-items: center;
width: 100%; width: 100%;
min-height: 42px;
border: 0; border: 0;
border-radius: 12px; border-radius: 12px;
background: transparent; background: transparent;
@@ -322,6 +326,13 @@
color: var(--app-brand-blue); color: var(--app-brand-blue);
} }
.app-user-panel a:focus-visible,
.app-user-panel button:focus-visible,
.app-user-trigger:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 0, 120, 0.10);
}
.app-user-panel form { .app-user-panel form {
margin: 0; margin: 0;
} }

View File

@@ -20,10 +20,6 @@
<h1>{% trans "Profil" %}</h1> <h1>{% trans "Profil" %}</h1>
<p>{% trans "Ihre aktuelle Workdock-Kontoübersicht und wichtige Sicherheitsaktionen." %}</p> <p>{% trans "Ihre aktuelle Workdock-Kontoübersicht und wichtige Sicherheitsaktionen." %}</p>
</div> </div>
<div class="account-hero-badges">
<span class="account-chip">{{ role_label }}</span>
<span class="account-chip account-chip-muted">{{ account_user.username }}</span>
</div>
</section> </section>
<div class="account-layout"> <div class="account-layout">
@@ -58,14 +54,6 @@
<p>{{ account_user.email|default:account_user.username }}</p> <p>{{ account_user.email|default:account_user.username }}</p>
</div> </div>
<div class="account-profile-meta"> <div class="account-profile-meta">
<div>
<span>{% trans "Rolle" %}</span>
<strong>{{ role_label }}</strong>
</div>
<div>
<span>{% trans "Benutzername" %}</span>
<strong>{{ account_user.username }}</strong>
</div>
<div> <div>
<span>{% trans "Position" %}</span> <span>{% trans "Position" %}</span>
<strong>{{ account_user_profile.job_title|default:"-" }}</strong> <strong>{{ account_user_profile.job_title|default:"-" }}</strong>
@@ -171,29 +159,36 @@
<section class="account-panel"> <section class="account-panel">
<div class="account-panel-head"> <div class="account-panel-head">
<h2>{% trans "Sicherheit & Aktionen" %}</h2> <h2>{% trans "Sicherheit & Aktionen" %}</h2>
<p>{% trans "Direkte Aktionen für Ihr Workdock-Konto." %}</p>
</div> </div>
<div class="account-action-grid"> <div class="account-security-overview">
<a class="account-action-card" href="{% url 'password_change' %}"> <div class="account-security-item">
<strong>{% trans "Passwort ändern" %}</strong> <span>{% trans "TOTP" %}</span>
<span>{% trans "Aktualisieren Sie Ihr Passwort direkt im Konto." %}</span> <strong>{% if account_user_profile.totp_enabled %}{% trans "Aktiv" %}{% else %}{% trans "Aus" %}{% endif %}</strong>
</a> <p>
<div class="account-action-card{% if account_user_profile.totp_enabled %}{% else %} account-action-card-muted{% endif %}">
<strong>{% trans "TOTP" %}</strong>
<span>
{% if account_user_profile.totp_enabled %} {% if account_user_profile.totp_enabled %}
{% trans "Zweiter Faktor ist aktiv und wird bei der Anmeldung geprüft." %} {% trans "Anmeldung wird zusätzlich mit einem zweiten Faktor geschützt." %}
{% else %} {% else %}
{% trans "Standardmäßig deaktiviert. Kann hier jederzeit aktiviert werden." %} {% trans "Optional. Kann bei Bedarf direkt unten aktiviert werden." %}
{% endif %} {% endif %}
</span> </p>
</div> </div>
<div class="account-security-item">
<div class="account-action-card account-action-card-muted"> <span>{% trans "Recovery-Codes" %}</span>
<strong>{% trans "Sitzung" %}</strong> <strong>
<span>{% trans "Sie können sich jederzeit sicher vom aktuellen Gerät abmelden." %}</span> {% if account_user_profile.totp_enabled %}
{{ account_user_profile.totp_recovery_codes|length }}
{% else %}
-
{% endif %}
</strong>
<p>
{% if account_user_profile.totp_enabled %}
{% trans "Einmal-Codes für Notfälle oder verlorene Authenticator-Geräte." %}
{% else %}
{% trans "Werden automatisch erzeugt, sobald TOTP aktiviert wird." %}
{% endif %}
</p>
</div> </div>
</div> </div>
@@ -241,18 +236,52 @@
<button class="btn btn-secondary" type="submit">{% trans "TOTP deaktivieren" %}</button> <button class="btn btn-secondary" type="submit">{% trans "TOTP deaktivieren" %}</button>
</div> </div>
</form> </form>
{% else %} <form class="account-totp-form" method="post">
<div class="account-detail-grid"> {% csrf_token %}
<div class="account-detail"> <input type="hidden" name="account_form" value="totp_regenerate_codes" />
<span>{% trans "Manueller Schlüssel" %}</span> <div class="account-form-grid">
<strong class="account-secret">{{ totp_pending_secret }}</strong> {% for field in totp_regenerate_form %}
<div class="account-form-field{% if field.errors %} has-error{% endif %}">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<div class="account-form-error">{{ field.errors|join:", " }}</div>
{% endif %}
</div>
{% endfor %}
</div> </div>
<div class="account-detail"> <div class="account-inline-actions">
<span>{% trans "Setup-Link" %}</span> <button class="btn btn-primary" type="submit">{% trans "Recovery-Codes neu erzeugen" %}</button>
<strong class="account-secret">{{ totp_otpauth_uri }}</strong> </div>
</form>
{% else %}
<div class="account-qr-card">
{% if totp_qr_svg %}
{{ totp_qr_svg|safe }}
{% endif %}
<div class="account-secret-panel">
<div class="account-secret-head">
<div>
<span>{% trans "Manueller Schlüssel" %}</span>
<strong>{% trans "Nur bei Bedarf anzeigen" %}</strong>
</div>
<button
class="btn btn-secondary account-secret-toggle"
type="button"
data-secret-toggle
aria-expanded="false"
aria-controls="totp-manual-secret"
title="{% trans 'Manuellen Schlüssel anzeigen oder ausblenden' %}"
>
<span data-secret-toggle-icon></span>
</button>
</div>
<div class="account-secret-body is-hidden" id="totp-manual-secret" data-secret-body>
<strong class="account-secret">{{ totp_pending_secret }}</strong>
</div>
</div> </div>
</div> </div>
<p class="mini">{% trans "Wenn Ihre App keinen QR-Code scannen kann, tragen Sie den Schlüssel oder den otpauth-Link manuell ein." %}</p> <p class="mini">{% trans "Scannen Sie den QR-Code mit Ihrer Authenticator-App. Den manuellen Schlüssel können Sie bei Bedarf einblenden." %}</p>
<form class="account-totp-form" method="post"> <form class="account-totp-form" method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="account_form" value="totp_enable" /> <input type="hidden" name="account_form" value="totp_enable" />
@@ -272,14 +301,22 @@
</div> </div>
</form> </form>
{% endif %} {% endif %}
</div>
<div class="account-actions"> {% if totp_recovery_codes %}
<a class="btn btn-primary" href="{% url 'password_change' %}">{% trans "Passwort ändern" %}</a> <div class="account-recovery-card">
<form method="post" action="{% url 'logout' %}"> <div class="account-panel-head">
{% csrf_token %} <div>
<button class="btn btn-secondary" type="submit">{% trans "Abmelden" %}</button> <h3>{% trans "Recovery-Codes" %}</h3>
</form> <p>{% trans "Diese Codes werden nur jetzt im Klartext angezeigt. Jeden Code können Sie genau einmal verwenden." %}</p>
</div>
</div>
<div class="account-recovery-grid">
{% for code in totp_recovery_codes %}
<div class="account-recovery-code">{{ code }}</div>
{% endfor %}
</div>
</div>
{% endif %}
</div> </div>
</section> </section>
</div> </div>
@@ -295,6 +332,9 @@
var cancel = document.querySelector('[data-account-edit-cancel="details"]'); var cancel = document.querySelector('[data-account-edit-cancel="details"]');
var view = document.querySelector('[data-account-edit-view="details"]'); var view = document.querySelector('[data-account-edit-view="details"]');
var form = document.querySelector('[data-account-edit-form="details"]'); var form = document.querySelector('[data-account-edit-form="details"]');
var secretToggle = document.querySelector('[data-secret-toggle]');
var secretBody = document.querySelector('[data-secret-body]');
var secretIcon = document.querySelector('[data-secret-toggle-icon]');
if (!toggle || !cancel || !view || !form) return; if (!toggle || !cancel || !view || !form) return;
function setMode(editing) { function setMode(editing) {
@@ -310,6 +350,17 @@
cancel.addEventListener('click', function () { cancel.addEventListener('click', function () {
setMode(false); setMode(false);
}); });
if (secretToggle && secretBody) {
secretToggle.addEventListener('click', function () {
var isOpen = secretToggle.getAttribute('aria-expanded') === 'true';
secretToggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
secretBody.classList.toggle('is-hidden', isOpen);
if (secretIcon) {
secretIcon.textContent = isOpen ? '◐' : '◑';
}
});
}
}()); }());
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -36,6 +36,10 @@
{{ form.otp_code.label_tag }}{{ form.otp_code }} {{ form.otp_code.label_tag }}{{ form.otp_code }}
<div class="mini">{% trans "Nur erforderlich, wenn TOTP für Ihr Konto aktiviert ist." %}</div> <div class="mini">{% trans "Nur erforderlich, wenn TOTP für Ihr Konto aktiviert ist." %}</div>
</div> </div>
<div class="field{% if form.recovery_code.errors %} has-error{% endif %}">
{{ form.recovery_code.label_tag }}{{ form.recovery_code }}
<div class="mini">{% trans "Alternativ können Sie einen einmaligen Recovery-Code verwenden." %}</div>
</div>
<button class="btn btn-primary" type="submit">{% trans "Anmelden" %}</button> <button class="btn btn-primary" type="submit">{% trans "Anmelden" %}</button>
</form> </form>
</div> </div>

View File

@@ -90,12 +90,15 @@ class AccountUISmokeTests(TestCase):
profile.refresh_from_db() profile.refresh_from_db()
self.assertTrue(profile.totp_enabled) self.assertTrue(profile.totp_enabled)
self.assertTrue(profile.totp_secret) self.assertTrue(profile.totp_secret)
self.assertEqual(len(profile.totp_recovery_codes), 8)
self.assertContains(response, 'Recovery-Codes')
def test_login_requires_totp_when_enabled(self): def test_login_requires_totp_when_enabled(self):
profile = self.user.profile profile = self.user.profile
profile.totp_secret = 'JBSWY3DPEHPK3PXP' profile.totp_secret = 'JBSWY3DPEHPK3PXP'
profile.totp_enabled = True profile.totp_enabled = True
profile.save(update_fields=['totp_secret', 'totp_enabled', 'updated_at']) profile.set_recovery_codes(['ABCDE-12345'])
profile.save(update_fields=['totp_secret', 'totp_enabled', 'totp_recovery_codes', 'updated_at'])
client = Client() client = Client()
response = client.post( response = client.post(
@@ -113,3 +116,13 @@ class AccountUISmokeTests(TestCase):
HTTP_HOST='localhost', HTTP_HOST='localhost',
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
client = Client()
response = client.post(
'/accounts/login/',
{'username': 'profile-user', 'password': 'secret-12345', 'recovery_code': 'ABCDE-12345'},
HTTP_HOST='localhost',
)
self.assertEqual(response.status_code, 302)
profile.refresh_from_db()
self.assertEqual(profile.totp_recovery_codes, [])

View File

@@ -5,6 +5,7 @@ import hashlib
import hmac import hmac
import secrets import secrets
import struct import struct
import string
from urllib.parse import quote from urllib.parse import quote
@@ -47,3 +48,19 @@ def build_otpauth_uri(secret: str, *, account_name: str, issuer: str) -> str:
label = quote(f'{issuer}:{account_name}') label = quote(f'{issuer}:{account_name}')
issuer_q = quote(issuer) issuer_q = quote(issuer)
return f'otpauth://totp/{label}?secret={secret}&issuer={issuer_q}&algorithm=SHA1&digits=6&period=30' return f'otpauth://totp/{label}?secret={secret}&issuer={issuer_q}&algorithm=SHA1&digits=6&period=30'
def normalize_recovery_code(value: str | None) -> str:
raw = (value or '').strip().upper().replace(' ', '')
return raw
def generate_recovery_codes(count: int = 8) -> list[str]:
alphabet = string.ascii_uppercase + string.digits
codes = []
for _ in range(count):
parts = []
for _part in range(2):
parts.append(''.join(secrets.choice(alphabet) for _ in range(5)))
codes.append('-'.join(parts))
return codes

View File

@@ -2,6 +2,7 @@ from pathlib import Path
from datetime import timedelta from datetime import timedelta
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
import json import json
from io import BytesIO
from functools import wraps from functools import wraps
from celery import current_app from celery import current_app
@@ -33,7 +34,7 @@ from .backup_ops import (
verify_backup_bundle, verify_backup_bundle,
) )
from .branding import get_branding_email_copy, get_company_email_domain, get_default_notification_templates, get_portal_trial_config, is_trial_expired from .branding import get_branding_email_copy, get_company_email_domain, get_default_notification_templates, get_portal_trial_config, is_trial_expired
from .forms import AccountAvatarForm, AccountDetailsForm, AccountTOTPDisableForm, AccountTOTPEnableForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm from .forms import AccountAvatarForm, AccountDetailsForm, AccountTOTPDisableForm, AccountTOTPEnableForm, AccountTOTPRegenerateRecoveryCodesForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm
from .form_builder import ( from .form_builder import (
DEFAULT_FIELD_ORDER, DEFAULT_FIELD_ORDER,
LOCKED_FIELD_RULES, LOCKED_FIELD_RULES,
@@ -46,7 +47,7 @@ from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfi
from .emailing import send_system_email from .emailing import send_system_email
from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability
from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud
from .totp import build_otpauth_uri, generate_totp_secret from .totp import build_otpauth_uri, generate_recovery_codes, generate_totp_secret
from .tasks import ( from .tasks import (
_generate_onboarding_intro_pdf, _generate_onboarding_intro_pdf,
_generate_onboarding_intro_session_pdf, _generate_onboarding_intro_session_pdf,
@@ -130,7 +131,9 @@ def healthz(request):
@login_required @login_required
def account_profile_page(request): def account_profile_page(request):
session_secret_key = 'account_totp_pending_secret' session_secret_key = 'account_totp_pending_secret'
session_codes_key = 'account_totp_recovery_codes'
profile, created = UserProfile.objects.get_or_create(user=request.user) profile, created = UserProfile.objects.get_or_create(user=request.user)
recovery_codes = request.session.pop(session_codes_key, [])
pending_totp_secret = request.session.get(session_secret_key) or '' pending_totp_secret = request.session.get(session_secret_key) or ''
if profile.totp_enabled: if profile.totp_enabled:
pending_totp_secret = '' pending_totp_secret = ''
@@ -143,6 +146,7 @@ def account_profile_page(request):
details_form = AccountDetailsForm(user=request.user, profile=profile) details_form = AccountDetailsForm(user=request.user, profile=profile)
totp_enable_form = AccountTOTPEnableForm(user=request.user, secret=pending_totp_secret) totp_enable_form = AccountTOTPEnableForm(user=request.user, secret=pending_totp_secret)
totp_disable_form = AccountTOTPDisableForm(user=request.user, profile=profile) totp_disable_form = AccountTOTPDisableForm(user=request.user, profile=profile)
totp_regenerate_form = AccountTOTPRegenerateRecoveryCodesForm(user=request.user, profile=profile)
account_edit_open = False account_edit_open = False
totp_edit_open = False totp_edit_open = False
if request.method == 'POST': if request.method == 'POST':
@@ -166,7 +170,9 @@ def account_profile_page(request):
totp_edit_open = True totp_edit_open = True
totp_enable_form = AccountTOTPEnableForm(request.POST, user=request.user, secret=pending_totp_secret) totp_enable_form = AccountTOTPEnableForm(request.POST, user=request.user, secret=pending_totp_secret)
if totp_enable_form.is_valid(): if totp_enable_form.is_valid():
profile.enable_totp(pending_totp_secret) recovery_codes = generate_recovery_codes()
profile.enable_totp(pending_totp_secret, recovery_codes)
request.session[session_codes_key] = recovery_codes
request.session.pop(session_secret_key, None) request.session.pop(session_secret_key, None)
messages.success(request, _('TOTP wurde aktiviert.')) messages.success(request, _('TOTP wurde aktiviert.'))
return redirect('account_profile_page') return redirect('account_profile_page')
@@ -180,10 +186,34 @@ def account_profile_page(request):
messages.success(request, _('TOTP wurde deaktiviert.')) messages.success(request, _('TOTP wurde deaktiviert.'))
return redirect('account_profile_page') return redirect('account_profile_page')
messages.error(request, _('TOTP konnte nicht deaktiviert werden.')) messages.error(request, _('TOTP konnte nicht deaktiviert werden.'))
elif form_kind == 'totp_regenerate_codes':
totp_edit_open = True
totp_regenerate_form = AccountTOTPRegenerateRecoveryCodesForm(request.POST, user=request.user, profile=profile)
if totp_regenerate_form.is_valid():
recovery_codes = generate_recovery_codes()
profile.set_recovery_codes(recovery_codes)
profile.save(update_fields=['totp_recovery_codes', 'updated_at'])
request.session[session_codes_key] = recovery_codes
messages.success(request, _('Recovery-Codes wurden neu erzeugt.'))
return redirect('account_profile_page')
messages.error(request, _('Recovery-Codes konnten nicht neu erzeugt werden.'))
branding_context = get_branding_email_copy() branding_context = get_branding_email_copy()
totp_account_name = (request.user.email or request.user.username or '').strip() totp_account_name = (request.user.email or request.user.username or '').strip()
totp_issuer = (branding_context.get('portal_title') or branding_context.get('company_name') or 'Workdock').strip() totp_issuer = (branding_context.get('portal_title') or branding_context.get('company_name') or 'Workdock').strip()
totp_otpauth_uri = '' if profile.totp_enabled else build_otpauth_uri(pending_totp_secret, account_name=totp_account_name, issuer=totp_issuer)
totp_qr_svg = ''
if totp_otpauth_uri:
try:
import qrcode
import qrcode.image.svg
qr_image = qrcode.make(totp_otpauth_uri, image_factory=qrcode.image.svg.SvgPathImage)
stream = BytesIO()
qr_image.save(stream)
totp_qr_svg = stream.getvalue().decode('utf-8')
except Exception:
totp_qr_svg = ''
return render( return render(
request, request,
'workflows/account_profile.html', 'workflows/account_profile.html',
@@ -194,11 +224,14 @@ def account_profile_page(request):
'details_form': details_form, 'details_form': details_form,
'totp_enable_form': totp_enable_form, 'totp_enable_form': totp_enable_form,
'totp_disable_form': totp_disable_form, 'totp_disable_form': totp_disable_form,
'totp_regenerate_form': totp_regenerate_form,
'account_edit_open': account_edit_open, 'account_edit_open': account_edit_open,
'totp_edit_open': totp_edit_open, 'totp_edit_open': totp_edit_open,
'role_label': get_user_role_label(request.user), 'role_label': get_user_role_label(request.user),
'totp_pending_secret': pending_totp_secret, 'totp_pending_secret': pending_totp_secret,
'totp_otpauth_uri': '' if profile.totp_enabled else build_otpauth_uri(pending_totp_secret, account_name=totp_account_name, issuer=totp_issuer), 'totp_otpauth_uri': totp_otpauth_uri,
'totp_qr_svg': totp_qr_svg,
'totp_recovery_codes': recovery_codes,
}, },
) )