From cb2d7f35adc4bbe053538760d6c7728202fbe719 Mon Sep 17 00:00:00 2001
From: Julian Rother <julian@cccv.de>
Date: Mon, 30 Aug 2021 23:40:35 +0200
Subject: [PATCH] Restrict password alphabet to SASLprep-safe ASCII subset

Prior to this change user passwords were not validated on change aside from
their length, but validated on login/bind by ldap3 with SASLprep. Instead of
using SASLprep on password change, this change restricts passwords to 7-bit
ASCII without control characters. Control characters are forbidden by
SASLprep. Multi-byte characters are uncommon in password, especially in those
generated by password managers. This ensures that passwords are always
SASLprep-safe without implementing the rather complex SASLprep algorithm. It
also allows us to fully describe the alphabet restrictions in the relevant
forms.

Fixes #100
---
 tests/test_selfservice.py                     |  13 +++
 tests/test_user.py                            |  23 +++-
 .../templates/selfservice/self.html           |   4 +-
 .../templates/selfservice/set_password.html   |   6 +-
 uffd/signup/templates/signup/start.html       |   4 +-
 uffd/translations/de/LC_MESSAGES/messages.mo  | Bin 30920 -> 30889 bytes
 uffd/translations/de/LC_MESSAGES/messages.po  |  99 +++++++++---------
 uffd/user/models.py                           |  19 +++-
 uffd/user/templates/user/show.html            |   4 +-
 uffd/user/views_user.py                       |   3 +
 10 files changed, 112 insertions(+), 63 deletions(-)

diff --git a/tests/test_selfservice.py b/tests/test_selfservice.py
index f36cfe93..da3c94dd 100644
--- a/tests/test_selfservice.py
+++ b/tests/test_selfservice.py
@@ -101,6 +101,19 @@ class TestSelfservice(UffdTestCase):
 		self.assertFalse(ldap.test_user_bind(_user.dn, 'shortpw'))
 		self.assertTrue(ldap.test_user_bind(_user.dn, 'userpassword'))
 
+	# Regression test for #100 (login not possible if password contains character disallowed by SASLprep)
+	def test_change_password_samlprep_invalid(self):
+		self.login_as('user')
+		user = request.user
+		r = self.client.post(path=url_for('selfservice.change_password'),
+			data={'password1': 'shortpw\n', 'password2': 'shortpw\n'},
+			follow_redirects=True)
+		dump('change_password_samlprep_invalid', r)
+		self.assertEqual(r.status_code, 200)
+		_user = request.user
+		self.assertFalse(ldap.test_user_bind(_user.dn, 'shortpw\n'))
+		self.assertTrue(ldap.test_user_bind(_user.dn, 'userpassword'))
+
 	def test_change_password_mismatch(self):
 		self.login_as('user')
 		user = request.user
diff --git a/tests/test_user.py b/tests/test_user.py
index 962840db..ea6bb9a6 100644
--- a/tests/test_user.py
+++ b/tests/test_user.py
@@ -193,6 +193,7 @@ class TestUserViews(UffdTestCase):
 		self.assertEqual(user_updated.uid, user_unupdated.uid)
 		self.assertEqual(user_updated.loginname, user_unupdated.loginname)
 		self.assertTrue(ldap.test_user_bind(user_updated.dn, 'newpassword'))
+		self.assertFalse(ldap.test_user_bind(user_updated.dn, self.test_data.get('user').get('password')))
 
 	def test_update_invalid_password(self):
 		user_unupdated = self.get_user()
@@ -201,10 +202,28 @@ class TestUserViews(UffdTestCase):
 		r = self.client.post(path=url_for('user.update', uid=user_unupdated.uid),
 			data={'loginname': 'testuser', 'mail': 'newuser@example.com', 'displayname': 'New User',
 			'password': 'A'}, follow_redirects=True)
-		dump('user_update_password', r)
+		dump('user_update_invalid_password', r)
 		self.assertEqual(r.status_code, 200)
 		user_updated = self.get_user()
 		self.assertFalse(ldap.test_user_bind(user_updated.dn, 'A'))
+		self.assertTrue(ldap.test_user_bind(user_updated.dn, self.test_data.get('user').get('password')))
+		self.assertEqual(user_updated.displayname, user_unupdated.displayname)
+		self.assertEqual(user_updated.mail, user_unupdated.mail)
+		self.assertEqual(user_updated.loginname, user_unupdated.loginname)
+
+	# Regression test for #100 (login not possible if password contains character disallowed by SASLprep)
+	def test_update_saslprep_invalid_password(self):
+		user_unupdated = self.get_user()
+		r = self.client.get(path=url_for('user.show', uid=user_unupdated.uid), follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		r = self.client.post(path=url_for('user.update', uid=user_unupdated.uid),
+			data={'loginname': 'testuser', 'mail': 'newuser@example.com', 'displayname': 'New User',
+			'password': 'newpassword\n'}, follow_redirects=True)
+		dump('user_update_saslprep_invalid_password', r)
+		self.assertEqual(r.status_code, 200)
+		user_updated = self.get_user()
+		self.assertFalse(ldap.test_user_bind(user_updated.dn, 'newpassword\n'))
+		self.assertTrue(ldap.test_user_bind(user_updated.dn, self.test_data.get('user').get('password')))
 		self.assertEqual(user_updated.displayname, user_unupdated.displayname)
 		self.assertEqual(user_updated.mail, user_unupdated.mail)
 		self.assertEqual(user_updated.loginname, user_unupdated.loginname)
@@ -223,6 +242,7 @@ class TestUserViews(UffdTestCase):
 		self.assertEqual(user_updated.mail, user_unupdated.mail)
 		self.assertEqual(user_updated.loginname, user_unupdated.loginname)
 		self.assertFalse(ldap.test_user_bind(user_updated.dn, 'newpassword'))
+		self.assertTrue(ldap.test_user_bind(user_updated.dn, self.test_data.get('user').get('password')))
 
 	def test_update_invalid_display_name(self):
 		user_unupdated = self.get_user()
@@ -238,6 +258,7 @@ class TestUserViews(UffdTestCase):
 		self.assertEqual(user_updated.mail, user_unupdated.mail)
 		self.assertEqual(user_updated.loginname, user_unupdated.loginname)
 		self.assertFalse(ldap.test_user_bind(user_updated.dn, 'newpassword'))
+		self.assertTrue(ldap.test_user_bind(user_updated.dn, self.test_data.get('user').get('password')))
 
 	def test_show(self):
 		r = self.client.get(path=url_for('user.show', uid=self.get_user().uid), follow_redirects=True)
diff --git a/uffd/selfservice/templates/selfservice/self.html b/uffd/selfservice/templates/selfservice/self.html
index edee7bdc..158e3f3d 100644
--- a/uffd/selfservice/templates/selfservice/self.html
+++ b/uffd/selfservice/templates/selfservice/self.html
@@ -47,9 +47,9 @@
 	<div class="col-12 col-md-7">
 		<form class="form" action="{{ url_for("selfservice.change_password") }}" method="POST">
 			<div class="form-group">
-				<input type="password" class="form-control" id="user-password1" name="password1" placeholder="{{_("New Password")}}" required>
+				<input type="password" class="form-control" id="user-password1" name="password1" placeholder="{{_("New Password")}}" minlength={{ User.PASSWORD_MINLEN }} maxlength={{ User.PASSWORD_MAXLEN }} pattern="{{ User.PASSWORD_REGEX }}" required>
 				<small class="form-text text-muted">
-					{{_('At least 8 and at most 256 characters, no other special requirements.')}}
+					{{ User.PASSWORD_DESCRIPTION|safe }}
 				</small>
 			</div>
 			<div class="form-group">
diff --git a/uffd/selfservice/templates/selfservice/set_password.html b/uffd/selfservice/templates/selfservice/set_password.html
index 42eaa866..ff4c4478 100644
--- a/uffd/selfservice/templates/selfservice/set_password.html
+++ b/uffd/selfservice/templates/selfservice/set_password.html
@@ -12,14 +12,14 @@
 		</div>
 		<div class="form-group col-12">
 			<label for="user-password1">{{_("New Password")}}</label>
-			<input type="password" class="form-control" id="user-password1" name="password1" required="required" tabindex = "2">
+			<input type="password" class="form-control" id="user-password1" name="password1" tabindex="2" minlength={{ User.PASSWORD_MINLEN }} maxlength={{ User.PASSWORD_MAXLEN }} pattern="{{ User.PASSWORD_REGEX }}" required>
 			<small class="form-text text-muted">
-				{{_("At least 8 and at most 256 characters, no other special requirements. But please use a password manager.")}}
+				{{ User.PASSWORD_DESCRIPTION|safe }}
 			</small>
 		</div>
 		<div class="form-group col-12">
 			<label for="user-password2">{{_("Repeat Password")}}</label>
-			<input type="password" class="form-control" id="user-password2" name="password2" required="required" tabindex = "2">
+			<input type="password" class="form-control" id="user-password2" name="password2" tabindex="3" required>
 		</div>
 		<div class="form-group col-12">
 			<button type="submit" class="btn btn-primary btn-block" tabindex = "3">{{_("Set password")}}</button>
diff --git a/uffd/signup/templates/signup/start.html b/uffd/signup/templates/signup/start.html
index a60dbdb2..ad150f4b 100644
--- a/uffd/signup/templates/signup/start.html
+++ b/uffd/signup/templates/signup/start.html
@@ -44,9 +44,9 @@
 		</div>
 		<div class="form-group col-12">
 			<label for="user-password1">{{_('Password')}}</label>
-			<input type="password" class="form-control" id="user-password1" name="password1" minlength=8 maxlength=256 required>
+			<input type="password" class="form-control" id="user-password1" name="password1" minlength={{ User.PASSWORD_MINLEN }} maxlength={{ User.PASSWORD_MAXLEN }} pattern="{{ User.PASSWORD_REGEX }}" required>
 			<small class="form-text text-muted">
-				{{_("At least 8 and at most 256 characters, no other special requirements. But please use a password manager.")}}
+				{{ User.PASSWORD_DESCRIPTION|safe }}
 			</small>
 		</div>
 		<div class="form-group col-12">
diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo
index 73a0ce629d71d5336446229934354d0b64f25f98..e58966921aed2726349395182b5de736f144f297 100644
GIT binary patch
delta 5164
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

delta 5032
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

diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po
index 3e355080..bae852dd 100644
--- a/uffd/translations/de/LC_MESSAGES/messages.po
+++ b/uffd/translations/de/LC_MESSAGES/messages.po
@@ -7,7 +7,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PROJECT VERSION\n"
 "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2021-08-13 14:24+0200\n"
+"POT-Creation-Date: 2021-08-30 23:22+0200\n"
 "PO-Revision-Date: 2021-05-25 21:18+0200\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language: de\n"
@@ -267,7 +267,7 @@ msgstr "Enthaltene Rollen"
 #: uffd/role/templates/role/list.html:14
 #: uffd/rolemod/templates/rolemod/list.html:9
 #: uffd/rolemod/templates/rolemod/show.html:46
-#: uffd/selfservice/templates/selfservice/self.html:103
+#: uffd/selfservice/templates/selfservice/self.html:101
 #: uffd/user/templates/group/list.html:10
 #: uffd/user/templates/group/show.html:11 uffd/user/templates/user/show.html:95
 #: uffd/user/templates/user/show.html:127
@@ -278,7 +278,7 @@ msgstr "Name"
 #: uffd/role/templates/role/list.html:15 uffd/role/templates/role/show.html:48
 #: uffd/rolemod/templates/rolemod/list.html:10
 #: uffd/rolemod/templates/rolemod/show.html:28
-#: uffd/selfservice/templates/selfservice/self.html:104
+#: uffd/selfservice/templates/selfservice/self.html:102
 #: uffd/user/templates/group/list.html:11 uffd/user/templates/user/show.html:96
 #: uffd/user/templates/user/show.html:128
 msgid "Description"
@@ -426,7 +426,7 @@ msgid "Two-factor authentication failed"
 msgstr "Zwei-Faktor-Authentifizierung fehlgeschlagen"
 
 #: uffd/mfa/templates/mfa/auth.html:12
-#: uffd/selfservice/templates/selfservice/self.html:69
+#: uffd/selfservice/templates/selfservice/self.html:67
 msgid "Two-Factor Authentication"
 msgstr "Zwei-Faktor-Authentifizierung"
 
@@ -473,12 +473,12 @@ msgid "Disable two-factor authentication"
 msgstr "Zwei-Faktor-Authentifizierung (2FA) deaktivieren"
 
 #: uffd/mfa/templates/mfa/setup.html:18
-#: uffd/selfservice/templates/selfservice/self.html:75
+#: uffd/selfservice/templates/selfservice/self.html:73
 msgid "Two-factor authentication is currently <strong>enabled</strong>."
 msgstr "Die Zwei-Faktor-Authentifizierung ist derzeit <strong>aktiviert</strong>."
 
 #: uffd/mfa/templates/mfa/setup.html:20
-#: uffd/selfservice/templates/selfservice/self.html:77
+#: uffd/selfservice/templates/selfservice/self.html:75
 msgid "Two-factor authentication is currently <strong>disabled</strong>."
 msgstr ""
 "Die Zwei-Faktor-Authentifizierung ist derzeit "
@@ -758,11 +758,11 @@ msgstr ""
 
 #: uffd/role/views.py:44 uffd/rolemod/views.py:36 uffd/rolemod/views.py:45
 #: uffd/rolemod/views.py:60 uffd/session/views.py:130
-#: uffd/user/views_group.py:14 uffd/user/views_user.py:22
+#: uffd/user/views_group.py:14 uffd/user/views_user.py:25
 msgid "Access denied"
 msgstr "Zugriff verweigert"
 
-#: uffd/role/views.py:51 uffd/selfservice/templates/selfservice/self.html:88
+#: uffd/role/views.py:51 uffd/selfservice/templates/selfservice/self.html:86
 #: uffd/user/templates/user/list.html:20 uffd/user/templates/user/show.html:21
 #: uffd/user/templates/user/show.html:90
 msgid "Roles"
@@ -797,7 +797,7 @@ msgid "Set as default"
 msgstr "Als Default setzen"
 
 #: uffd/role/templates/role/show.html:19 uffd/role/templates/role/show.html:21
-#: uffd/selfservice/templates/selfservice/self.html:119
+#: uffd/selfservice/templates/selfservice/self.html:117
 #: uffd/user/templates/user/show.html:11
 msgid "Are you sure?"
 msgstr "Wirklich fortfahren?"
@@ -980,7 +980,7 @@ msgid "Forgot password"
 msgstr "Passwort vergessen"
 
 #: uffd/selfservice/templates/selfservice/forgot_password.html:14
-#: uffd/selfservice/templates/selfservice/self.html:22
+#: uffd/selfservice/templates/selfservice/self.html:21
 #: uffd/session/templates/session/login.html:14
 #: uffd/signup/templates/signup/start.html:19
 #: uffd/user/templates/user/list.html:18 uffd/user/templates/user/show.html:48
@@ -1023,35 +1023,35 @@ msgstr ""
 msgid "Changes may take serveral minutes to be visible in all services."
 msgstr "Änderungen sind erst nach einigen Minuten in allen Diensten sichtbar."
 
-#: uffd/selfservice/templates/selfservice/self.html:27
+#: uffd/selfservice/templates/selfservice/self.html:25
 #: uffd/signup/templates/signup/start.html:32
 #: uffd/user/templates/user/list.html:19 uffd/user/templates/user/show.html:63
 msgid "Display Name"
 msgstr "Anzeigename"
 
-#: uffd/selfservice/templates/selfservice/self.html:31
+#: uffd/selfservice/templates/selfservice/self.html:29
 #: uffd/signup/templates/signup/start.html:39
 msgid "E-Mail Address"
 msgstr "E-Mail-Adresse"
 
-#: uffd/selfservice/templates/selfservice/self.html:34
+#: uffd/selfservice/templates/selfservice/self.html:32
 msgid "We will send you a confirmation mail to this address if you change it"
 msgstr ""
 "Wir werden dir eine Bestätigungsmail zum Setzen der neuen E-Mail-Adresse "
 "senden."
 
-#: uffd/selfservice/templates/selfservice/self.html:37
+#: uffd/selfservice/templates/selfservice/self.html:35
 msgid "Update Profile"
 msgstr "Änderungen speichern"
 
-#: uffd/selfservice/templates/selfservice/self.html:46
+#: uffd/selfservice/templates/selfservice/self.html:44
 #: uffd/session/templates/session/login.html:18
 #: uffd/signup/templates/signup/start.html:46
 #: uffd/user/templates/user/show.html:77
 msgid "Password"
 msgstr "Passwort"
 
-#: uffd/selfservice/templates/selfservice/self.html:47
+#: uffd/selfservice/templates/selfservice/self.html:45
 msgid ""
 "Your login password for the Single-Sign-On. Only enter it on the Single-"
 "Sign-On login page! No other legit websites will ask you for this "
@@ -1062,27 +1062,22 @@ msgstr ""
 " Webseite wird dich nach diesem Passwort fragen. Es wird auch niemals für"
 " Support-Anfragen benötigt."
 
-#: uffd/selfservice/templates/selfservice/self.html:52
+#: uffd/selfservice/templates/selfservice/self.html:50
 #: uffd/selfservice/templates/selfservice/set_password.html:14
 msgid "New Password"
 msgstr "Neues Passwort"
 
-#: uffd/selfservice/templates/selfservice/self.html:54
-#: uffd/user/templates/user/show.html:84
-msgid "At least 8 and at most 256 characters, no other special requirements."
-msgstr "Mindestens 8 und maximal 256 Zeichen, keine weiteren Einschränkungen."
-
-#: uffd/selfservice/templates/selfservice/self.html:58
+#: uffd/selfservice/templates/selfservice/self.html:56
 #: uffd/selfservice/templates/selfservice/set_password.html:21
 #: uffd/signup/templates/signup/start.html:53
 msgid "Repeat Password"
 msgstr "Passwort wiederholen"
 
-#: uffd/selfservice/templates/selfservice/self.html:60
+#: uffd/selfservice/templates/selfservice/self.html:58
 msgid "Change Password"
 msgstr "Passwort ändern"
 
-#: uffd/selfservice/templates/selfservice/self.html:70
+#: uffd/selfservice/templates/selfservice/self.html:68
 msgid ""
 "Setting up Two-Factor Authentication (2FA) adds an additional step to the"
 " Single-Sign-On login and increases the security of your account "
@@ -1092,11 +1087,11 @@ msgstr ""
 "Anmeldung im Single-Sign-On hinzu und verbessert damit die Sicherheit "
 "deines Accounts erheblich."
 
-#: uffd/selfservice/templates/selfservice/self.html:80
+#: uffd/selfservice/templates/selfservice/self.html:78
 msgid "Manage two-factor authentication"
 msgstr "Zwei-Faktor-Authentifizierung (2FA) verwalten"
 
-#: uffd/selfservice/templates/selfservice/self.html:89
+#: uffd/selfservice/templates/selfservice/self.html:87
 msgid ""
 "Aside from a set of base permissions, your roles determine the "
 "permissions of your account."
@@ -1104,7 +1099,7 @@ msgstr ""
 "Deine Berechtigungen werden, von einigen Basis-Berechtigungen abgesehen, "
 "von deinen Rollen bestimmt"
 
-#: uffd/selfservice/templates/selfservice/self.html:91
+#: uffd/selfservice/templates/selfservice/self.html:89
 #, python-format
 msgid ""
 "See <a href=\"%(services_url)s\">Services</a> for an overview of your "
@@ -1113,17 +1108,17 @@ msgstr ""
 "Auf <a href=\"%(services_url)s\">Dienste</a> erhälst du einen Überblick "
 "über deine aktuellen Berechtigungen."
 
-#: uffd/selfservice/templates/selfservice/self.html:96
+#: uffd/selfservice/templates/selfservice/self.html:94
 msgid "Administrators and role moderators can invite you to new roles."
 msgstr ""
 "Accounts mit Adminrechten oder Rollen-Moderationsrechten können dich zu "
 "Rollen einladen."
 
-#: uffd/selfservice/templates/selfservice/self.html:98
+#: uffd/selfservice/templates/selfservice/self.html:96
 msgid "Administrators can add new roles to your account."
 msgstr "Accounts mit Adminrechten können dich zu neuen Rollen hinzufügen."
 
-#: uffd/selfservice/templates/selfservice/self.html:113
+#: uffd/selfservice/templates/selfservice/self.html:111
 msgid ""
 "Some permissions in this role require you to setup two-factor "
 "authentication"
@@ -1131,11 +1126,11 @@ msgstr ""
 "Einige Berechtigungen dieser Rolle erfordern das Einrichten von Zwei-"
 "Faktor-Authentifikation"
 
-#: uffd/selfservice/templates/selfservice/self.html:120
+#: uffd/selfservice/templates/selfservice/self.html:118
 msgid "Leave"
 msgstr "Verlassen"
 
-#: uffd/selfservice/templates/selfservice/self.html:128
+#: uffd/selfservice/templates/selfservice/self.html:126
 msgid "You currently don't have any roles"
 msgstr "Du hast derzeit keine Rollen"
 
@@ -1143,15 +1138,6 @@ msgstr "Du hast derzeit keine Rollen"
 msgid "Reset password"
 msgstr "Passwort zurücksetzen"
 
-#: uffd/selfservice/templates/selfservice/set_password.html:17
-#: uffd/signup/templates/signup/start.html:49
-msgid ""
-"At least 8 and at most 256 characters, no other special requirements. But"
-" please use a password manager."
-msgstr ""
-"Mindestens 8 und maximal 256 Zeichen, keine weiteren Einschränkungen. "
-"Bitte verwende einen Passwort-Manager."
-
 #: uffd/selfservice/templates/selfservice/set_password.html:25
 msgid "Set password"
 msgstr "Passwort setzen"
@@ -1417,45 +1403,56 @@ msgstr "Ändern"
 msgid "About uffd"
 msgstr "Über uffd"
 
+#: uffd/user/models.py:52
+#, python-format
+msgid ""
+"At least %(minlen)d and at most %(maxlen)d characters. Only letters, "
+"digits, spaces and some symbols (<code>%(symbols)s</code>) allowed. "
+"Please use a password manager."
+msgstr ""
+"%(minlen)d bis %(maxlen)d Zeichen. Nur Buchstaben, Ziffern, Leerzeichen "
+"und manche Symbole (<code>%(symbols)s</code>), keine Umlaute. Bitte "
+"verwende einen Passwort-Manager."
+
 #: uffd/user/views_group.py:21
 msgid "Groups"
 msgstr "Gruppen"
 
-#: uffd/user/views_user.py:29
+#: uffd/user/views_user.py:32
 msgid "Users"
 msgstr "Accounts"
 
-#: uffd/user/views_user.py:49
+#: uffd/user/views_user.py:52
 msgid "Login name does not meet requirements"
 msgstr "Anmeldename entspricht nicht den Anforderungen"
 
-#: uffd/user/views_user.py:54
+#: uffd/user/views_user.py:57
 msgid "Mail is invalid"
 msgstr "E-Mail-Adresse nicht valide"
 
-#: uffd/user/views_user.py:58
+#: uffd/user/views_user.py:61
 msgid "Display name does not meet requirements"
 msgstr "Anzeigename entspricht nicht den Anforderungen"
 
-#: uffd/user/views_user.py:63
+#: uffd/user/views_user.py:66
 msgid "Password is invalid"
 msgstr "Passwort ist ungültig"
 
-#: uffd/user/views_user.py:77
+#: uffd/user/views_user.py:80
 msgid "Service user created"
 msgstr "Service-Account erstellt"
 
-#: uffd/user/views_user.py:80
+#: uffd/user/views_user.py:83
 msgid "User created. We sent the user a password reset link by mail"
 msgstr ""
 "Account erstellt. E-Mail mit einem Link zum Setzen des Passworts wurde "
 "versendet."
 
-#: uffd/user/views_user.py:82
+#: uffd/user/views_user.py:85
 msgid "User updated"
 msgstr "Account aktualisiert"
 
-#: uffd/user/views_user.py:93
+#: uffd/user/views_user.py:96
 msgid "Deleted user"
 msgstr "Account gelöscht"
 
diff --git a/uffd/user/models.py b/uffd/user/models.py
index 81a6e363..3a3069f7 100644
--- a/uffd/user/models.py
+++ b/uffd/user/models.py
@@ -2,7 +2,8 @@ import secrets
 import string
 import re
 
-from flask import current_app
+from flask import current_app, escape
+from flask_babel import lazy_gettext
 from ldap3.utils.hashed import hashed, HASHED_SALTED_SHA512
 
 from uffd.ldap import ldap
@@ -36,6 +37,20 @@ def format_with_attributes(fmtstr, obj):
 	return fmtstr.format_map(ObjectAttributeDict(obj))
 
 class BaseUser(ldap.Model):
+	# Allows 8 to 256 ASCII letters (lower and upper case), digits, spaces and
+	# symbols/punctuation characters. It disallows control characters and
+	# non-ASCII characters to prevent setting passwords considered invalid by
+	# SASLprep.
+	#
+	# This REGEX ist used both in Python and JS.
+	PASSWORD_REGEX = '[ -~]*'
+	PASSWORD_MINLEN = 8
+	PASSWORD_MAXLEN = 256
+	PASSWORD_DESCRIPTION = lazy_gettext('At least %(minlen)d and at most %(maxlen)d characters. ' + \
+	                                    'Only letters, digits, spaces and some symbols (<code>%(symbols)s</code>) allowed. ' + \
+	                                    'Please use a password manager.',
+	                                    minlen=PASSWORD_MINLEN, maxlen=PASSWORD_MAXLEN, symbols=escape('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'))
+
 	ldap_search_base = lazyconfig_str('LDAP_USER_SEARCH_BASE')
 	ldap_filter_params = lazyconfig_list('LDAP_USER_SEARCH_FILTER')
 	ldap_object_classes = lazyconfig_list('LDAP_USER_OBJECTCLASSES')
@@ -129,7 +144,7 @@ class BaseUser(ldap.Model):
 		return True
 
 	def set_password(self, value):
-		if len(value) < 8 or len(value) > 256:
+		if len(value) < self.PASSWORD_MINLEN or len(value) > self.PASSWORD_MAXLEN or not re.fullmatch(self.PASSWORD_REGEX, value):
 			return False
 		self.password = value
 		return True
diff --git a/uffd/user/templates/user/show.html b/uffd/user/templates/user/show.html
index 59ef1bd1..052dd09a 100644
--- a/uffd/user/templates/user/show.html
+++ b/uffd/user/templates/user/show.html
@@ -76,12 +76,12 @@
 			<div class="form-group col">
 				<label for="user-loginname">{{_("Password")}}</label>
 				{% if user.uid %}
-				<input type="password" class="form-control" id="user-password" name="password" placeholder="●●●●●●●●">
+				<input type="password" class="form-control" id="user-password" name="password" placeholder="●●●●●●●●" minlength={{ User.PASSWORD_MINLEN }} maxlength={{ User.PASSWORD_MAXLEN }} pattern="{{ User.PASSWORD_REGEX }}">
 				{% else %}
 				<input type="password" class="form-control" id="user-password" name="password" placeholder="{{_("mail to set it will be sent")}}" readonly>
 				{% endif %}
 				<small class="form-text text-muted">
-					{{_("At least 8 and at most 256 characters, no other special requirements.")}}
+					{{ User.PASSWORD_DESCRIPTION|safe }}
 				</small>
 			</div>
 		</div>
diff --git a/uffd/user/views_user.py b/uffd/user/views_user.py
index ee5bc71e..2b273946 100644
--- a/uffd/user/views_user.py
+++ b/uffd/user/views_user.py
@@ -15,6 +15,9 @@ from uffd.ldap import ldap, LDAPCommitError
 from .models import User
 
 bp = Blueprint("user", __name__, template_folder='templates', url_prefix='/user/')
+
+bp.add_app_template_global(User, 'User')
+
 @bp.before_request
 @login_required()
 def user_acl(): #pylint: disable=inconsistent-return-statements
-- 
GitLab