From 620cf9ab2d1931fe52c33e09b5490fc38d521dbd Mon Sep 17 00:00:00 2001 From: Julian Rother <julian@cccv.de> Date: Tue, 25 Oct 2022 22:00:33 +0200 Subject: [PATCH] Unique email addresses Enforces uniqueness of (verified) email addresses across all users. Email addresses are compared case-insensitivly and Unicode-normalized. The new unique constraints are disabled by default and can be enabled with a CLI command. They are planned to become mandatory in uffd v3. A lot of software does not allow multiple users to share the same email address. This change prevents problems with such software. To enable this feature run the command: uffd-admin unique-email-addresses enable The commands reports any issues (e.g. existing duplicate addresses) that prevent enabling the feature. This change also introduces a generic mechanism to store feature flags in the database and improves error handling for login name constraint violations. --- tests/commands/test_unique_email_addresses.py | 51 +++++ tests/commands/test_user.py | 22 ++- tests/models/test_misc.py | 55 ++++++ tests/models/test_signup.py | 42 ++-- tests/models/test_user.py | 137 ++++++++++++- tests/views/test_selfservice.py | 18 +- tests/views/test_signup.py | 55 ++++-- tests/views/test_user.py | 112 ++++++++++- uffd/commands/__init__.py | 2 + uffd/commands/group.py | 5 +- uffd/commands/role.py | 5 +- uffd/commands/unique_email_addresses.py | 67 +++++++ uffd/commands/user.py | 14 +- .../468995a9c9ee_unique_email_addresses.py | 102 ++++++++++ uffd/models/__init__.py | 2 + uffd/models/misc.py | 41 ++++ uffd/models/signup.py | 7 + uffd/models/user.py | 60 +++++- uffd/templates/user/show.html | 4 + uffd/translations/de/LC_MESSAGES/messages.mo | Bin 39636 -> 40047 bytes uffd/translations/de/LC_MESSAGES/messages.po | 182 ++++++++++-------- uffd/views/selfservice.py | 6 +- uffd/views/signup.py | 1 + uffd/views/user.py | 141 ++++++++------ 24 files changed, 920 insertions(+), 211 deletions(-) create mode 100644 tests/commands/test_unique_email_addresses.py create mode 100644 tests/models/test_misc.py create mode 100644 uffd/commands/unique_email_addresses.py create mode 100644 uffd/migrations/versions/468995a9c9ee_unique_email_addresses.py create mode 100644 uffd/models/misc.py diff --git a/tests/commands/test_unique_email_addresses.py b/tests/commands/test_unique_email_addresses.py new file mode 100644 index 00000000..0f7edcfa --- /dev/null +++ b/tests/commands/test_unique_email_addresses.py @@ -0,0 +1,51 @@ +from uffd.database import db +from uffd.models import User, UserEmail, FeatureFlag + +from tests.utils import UffdTestCase + +class TestUniqueEmailAddressCommands(UffdTestCase): + def setUp(self): + super().setUp() + self.client.__exit__(None, None, None) + + def test_enable(self): + result = self.app.test_cli_runner().invoke(args=['unique-email-addresses', 'enable']) + self.assertEqual(result.exit_code, 0) + with self.app.test_request_context(): + self.assertTrue(FeatureFlag.unique_email_addresses) + + def test_enable_already_enabled(self): + with self.app.test_request_context(): + FeatureFlag.unique_email_addresses.enable() + db.session.commit() + result = self.app.test_cli_runner().invoke(args=['unique-email-addresses', 'enable']) + self.assertEqual(result.exit_code, 1) + + def test_enable_user_conflict(self): + with self.app.test_request_context(): + db.session.add(UserEmail(user=self.get_user(), address='foo@example.com')) + db.session.add(UserEmail(user=self.get_user(), address='FOO@example.com')) + db.session.commit() + result = self.app.test_cli_runner().invoke(args=['unique-email-addresses', 'enable']) + self.assertEqual(result.exit_code, 1) + + def test_enable_global_conflict(self): + with self.app.test_request_context(): + db.session.add(UserEmail(user=self.get_user(), address='foo@example.com', verified=True)) + db.session.add(UserEmail(user=self.get_admin(), address='FOO@example.com', verified=True)) + db.session.commit() + result = self.app.test_cli_runner().invoke(args=['unique-email-addresses', 'enable']) + self.assertEqual(result.exit_code, 1) + + def test_disable(self): + with self.app.test_request_context(): + FeatureFlag.unique_email_addresses.enable() + db.session.commit() + result = self.app.test_cli_runner().invoke(args=['unique-email-addresses', 'disable']) + self.assertEqual(result.exit_code, 0) + with self.app.test_request_context(): + self.assertFalse(FeatureFlag.unique_email_addresses) + + def test_disable_already_enabled(self): + result = self.app.test_cli_runner().invoke(args=['unique-email-addresses', 'disable']) + self.assertEqual(result.exit_code, 1) diff --git a/tests/commands/test_user.py b/tests/commands/test_user.py index bae05ebf..3612fc3b 100644 --- a/tests/commands/test_user.py +++ b/tests/commands/test_user.py @@ -1,5 +1,5 @@ from uffd.database import db -from uffd.models import User, Group, Role, RoleGroup +from uffd.models import User, Group, Role, RoleGroup, FeatureFlag from tests.utils import UffdTestCase @@ -36,6 +36,16 @@ class TestUserCLI(UffdTestCase): self.assertEqual(result.exit_code, 1) result = self.app.test_cli_runner().invoke(args=['user', 'create', 'testuser', '--mail', 'foobar@example.com']) # conflicting name self.assertEqual(result.exit_code, 1) + + with self.app.test_request_context(): + FeatureFlag.unique_email_addresses.enable() + db.session.commit() + result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser', '--mail', 'test@example.com']) # conflicting email address + self.assertEqual(result.exit_code, 1) + with self.app.test_request_context(): + FeatureFlag.unique_email_addresses.disable() + db.session.commit() + result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser', '--mail', 'newmail@example.com', '--displayname', 'New Display Name', '--password', 'newpassword', '--add-role', 'admin']) self.assertEqual(result.exit_code, 0) @@ -53,6 +63,16 @@ class TestUserCLI(UffdTestCase): self.assertEqual(result.exit_code, 1) result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--mail', '']) # invalid mail self.assertEqual(result.exit_code, 1) + + with self.app.test_request_context(): + FeatureFlag.unique_email_addresses.enable() + db.session.commit() + result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--mail', 'admin@example.com']) # conflicting mail + self.assertEqual(result.exit_code, 1) + with self.app.test_request_context(): + FeatureFlag.unique_email_addresses.disable() + db.session.commit() + result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--password', '']) # invalid password self.assertEqual(result.exit_code, 1) result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--displayname', '']) # invalid display name diff --git a/tests/models/test_misc.py b/tests/models/test_misc.py new file mode 100644 index 00000000..4b208658 --- /dev/null +++ b/tests/models/test_misc.py @@ -0,0 +1,55 @@ +from sqlalchemy.exc import IntegrityError + +from uffd.database import db +from uffd.models import FeatureFlag +from uffd.models.misc import feature_flag_table + +from tests.utils import UffdTestCase + +class TestFeatureFlagModel(UffdTestCase): + def test_disabled(self): + flag = FeatureFlag('foo') + self.assertFalse(flag) + self.assertFalse(db.session.execute(db.select([flag.expr])).scalar()) + + def test_enabled(self): + db.session.execute(db.insert(feature_flag_table).values(name='foo')) + flag = FeatureFlag('foo') + self.assertTrue(flag) + self.assertTrue(db.session.execute(db.select([flag.expr])).scalar()) + + def test_toggle(self): + flag = FeatureFlag('foo') + hooks_called = [] + + @flag.enable_hook + def enable_hook1(): + hooks_called.append('enable1') + + @flag.enable_hook + def enable_hook2(): + hooks_called.append('enable2') + + @flag.disable_hook + def disable_hook1(): + hooks_called.append('disable1') + + @flag.disable_hook + def disable_hook2(): + hooks_called.append('disable2') + + hooks_called.clear() + flag.enable() + self.assertTrue(flag) + self.assertEqual(hooks_called, ['enable1', 'enable2']) + hooks_called.clear() + flag.disable() + self.assertFalse(flag) + self.assertEqual(hooks_called, ['disable1', 'disable2']) + flag.disable() # does nothing + self.assertFalse(flag) + flag.enable() + self.assertTrue(flag) + with self.assertRaises(IntegrityError): + flag.enable() + self.assertTrue(flag) diff --git a/tests/models/test_signup.py b/tests/models/test_signup.py index cfed4fb2..acdeda15 100644 --- a/tests/models/test_signup.py +++ b/tests/models/test_signup.py @@ -1,7 +1,7 @@ import datetime from uffd.database import db -from uffd.models import Signup, User +from uffd.models import Signup, User, FeatureFlag from tests.utils import UffdTestCase, db_flush @@ -44,7 +44,7 @@ class TestSignupModel(UffdTestCase): self.assertEqual(signup.user_id, prev_id) def test_password(self): - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com') + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com') self.assertFalse(signup.password.verify('notsecret')) self.assertFalse(signup.password.verify('')) self.assertFalse(signup.password.verify('wrongpassword')) @@ -54,13 +54,13 @@ class TestSignupModel(UffdTestCase): def test_expired(self): # TODO: Find a better way to test this! - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret') self.assertFalse(signup.expired) signup.created = created=datetime.datetime.utcnow() - datetime.timedelta(hours=49) self.assertTrue(signup.expired) def test_completed(self): - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret') self.assertFalse(signup.completed) signup.finish('notsecret') db.session.commit() @@ -69,29 +69,29 @@ class TestSignupModel(UffdTestCase): self.assertTrue(signup.completed) def test_validate(self): - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret') self.assert_validate_valid(signup) self.assert_validate_valid(refetch_signup(signup)) def test_validate_completed(self): - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret') self.assert_finish_success(signup, 'notsecret') self.assert_validate_invalid(signup) self.assert_validate_invalid(refetch_signup(signup)) def test_validate_expired(self): - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret', created=datetime.datetime.utcnow()-datetime.timedelta(hours=49)) self.assert_validate_invalid(signup) self.assert_validate_invalid(refetch_signup(signup)) def test_validate_loginname(self): - signup = Signup(loginname='', displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname='', displayname='New User', mail='new@example.com', password='notsecret') self.assert_validate_invalid(signup) self.assert_validate_invalid(refetch_signup(signup)) def test_validate_displayname(self): - signup = Signup(loginname='newuser', displayname='', mail='test@example.com', password='notsecret') + signup = Signup(loginname='newuser', displayname='', mail='new@example.com', password='notsecret') self.assert_validate_invalid(signup) self.assert_validate_invalid(refetch_signup(signup)) @@ -101,52 +101,58 @@ class TestSignupModel(UffdTestCase): self.assert_validate_invalid(refetch_signup(signup)) def test_validate_password(self): - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com') + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com') self.assertFalse(signup.set_password('')) self.assert_validate_invalid(signup) self.assert_validate_invalid(refetch_signup(signup)) def test_validate_exists(self): - signup = Signup(loginname='testuser', displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname='testuser', displayname='New User', mail='new@example.com', password='notsecret') self.assert_validate_invalid(signup) self.assert_validate_invalid(refetch_signup(signup)) def test_finish(self): - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret') self.assert_finish_success(signup, 'notsecret') user = User.query.filter_by(loginname='newuser').one_or_none() self.assertEqual(user.loginname, 'newuser') self.assertEqual(user.displayname, 'New User') - self.assertEqual(user.primary_email.address, 'test@example.com') + self.assertEqual(user.primary_email.address, 'new@example.com') def test_finish_completed(self): - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret') self.assert_finish_success(signup, 'notsecret') self.assert_finish_failure(refetch_signup(signup), 'notsecret') def test_finish_expired(self): # TODO: Find a better way to test this! - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret', created=datetime.datetime.utcnow()-datetime.timedelta(hours=49)) self.assert_finish_failure(signup, 'notsecret') self.assert_finish_failure(refetch_signup(signup), 'notsecret') def test_finish_wrongpassword(self): - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com') + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com') self.assert_finish_failure(signup, '') self.assert_finish_failure(signup, 'wrongpassword') signup = refetch_signup(signup) self.assert_finish_failure(signup, '') self.assert_finish_failure(signup, 'wrongpassword') - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret') self.assert_finish_failure(signup, 'wrongpassword') self.assert_finish_failure(refetch_signup(signup), 'wrongpassword') def test_finish_duplicate(self): - signup = Signup(loginname='testuser', displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname='testuser', displayname='New User', mail='new@example.com', password='notsecret') self.assert_finish_failure(signup, 'notsecret') self.assert_finish_failure(refetch_signup(signup), 'notsecret') + def test_finish_duplicate_email_strict_uniqueness(self): + FeatureFlag.unique_email_addresses.enable() + db.session.commit() + signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret') + self.assert_finish_failure(signup, 'notsecret') + def test_duplicate(self): signup = Signup(loginname='newuser', displayname='New User', mail='test1@example.com', password='notsecret') self.assert_validate_valid(signup) diff --git a/tests/models/test_user.py b/tests/models/test_user.py index 42fabfcf..6d81e9ec 100644 --- a/tests/models/test_user.py +++ b/tests/models/test_user.py @@ -3,7 +3,7 @@ import datetime import sqlalchemy from uffd.database import db -from uffd.models import User, UserEmail, Group +from uffd.models import User, UserEmail, Group, FeatureFlag from tests.utils import UffdTestCase @@ -119,6 +119,30 @@ class TestUserModel(UffdTestCase): self.assertEqual({user.all_emails[0].address, user.all_emails[1].address}, {'foobar@example.com', 'other@example.com'}) class TestUserEmailModel(UffdTestCase): + def test_normalize_address(self): + ref = UserEmail.normalize_address('foo@example.com') + self.assertEqual(ref, UserEmail.normalize_address('foo@example.com')) + self.assertEqual(ref, UserEmail.normalize_address('Foo@Example.Com')) + self.assertEqual(ref, UserEmail.normalize_address(' foo@example.com ')) + self.assertNotEqual(ref, UserEmail.normalize_address('bar@example.com')) + self.assertNotEqual(ref, UserEmail.normalize_address('foo @example.com')) + # "No-Break Space" instead of SPACE (Unicode normalization + stripping) + self.assertEqual(ref, UserEmail.normalize_address('\u00A0foo@example.com ')) + # Pre-composed "Angstrom Sign" vs. "A" + "Combining Ring Above" (Unicode normalization) + self.assertEqual(UserEmail.normalize_address('\u212B@example.com'), UserEmail.normalize_address('A\u030A@example.com')) + + def test_address(self): + email = UserEmail() + self.assertIsNone(email.address) + self.assertIsNone(email.address_normalized) + email.address = 'Foo@example.com' + self.assertEqual(email.address, 'Foo@example.com') + self.assertEqual(email.address_normalized, UserEmail.normalize_address('Foo@example.com')) + with self.assertRaises(ValueError): + email.address = 'bar@example.com' + with self.assertRaises(ValueError): + email.address = None + def test_set_address(self): email = UserEmail() self.assertFalse(email.set_address('invalid')) @@ -134,6 +158,23 @@ class TestUserEmailModel(UffdTestCase): self.assertTrue(email.set_address('foobar@example.com')) self.assertEqual(email.address, 'foobar@example.com') + def test_verified(self): + email = UserEmail(user=self.get_user(), address='foo@example.com') + db.session.add(email) + self.assertEqual(email.verified, False) + self.assertEqual(UserEmail.query.filter_by(address='foo@example.com', verified=True).count(), 0) + self.assertEqual(UserEmail.query.filter_by(address='foo@example.com', verified=False).count(), 1) + email.verified = True + self.assertEqual(email.verified, True) + self.assertEqual(UserEmail.query.filter_by(address='foo@example.com', verified=True).count(), 1) + self.assertEqual(UserEmail.query.filter_by(address='foo@example.com', verified=False).count(), 0) + with self.assertRaises(ValueError): + email.verified = False + self.assertEqual(email.verified, True) + with self.assertRaises(ValueError): + email.verified = None + self.assertEqual(email.verified, True) + def test_verification(self): email = UserEmail(address='foo@example.com') self.assertFalse(email.finish_verification('test')) @@ -150,6 +191,100 @@ class TestUserEmailModel(UffdTestCase): self.assertFalse(email.verification_secret) self.assertTrue(email.verification_expired) + def test_enable_strict_constraints(self): + email = UserEmail(address='foo@example.com', user=self.get_user()) + db.session.add(email) + db.session.commit() + self.assertIsNone(email.enable_strict_constraints) + FeatureFlag.unique_email_addresses.enable() + self.assertTrue(email.enable_strict_constraints) + FeatureFlag.unique_email_addresses.disable() + self.assertIsNone(email.enable_strict_constraints) + + def assert_can_add_address(self, **kwargs): + user_email = UserEmail(**kwargs) + db.session.add(user_email) + db.session.commit() + db.session.delete(user_email) + db.session.commit() + + def assert_cannot_add_address(self, **kwargs): + with self.assertRaises(sqlalchemy.exc.IntegrityError): + db.session.add(UserEmail(**kwargs)) + db.session.commit() + db.session.rollback() + + def test_unique_constraints_old(self): + # The same user cannot add the same exact address multiple times, but + # different users can have the same address + user = self.get_user() + admin = self.get_admin() + db.session.add(UserEmail(user=user, address='foo@example.com')) + db.session.add(UserEmail(user=user, address='bar@example.com', verified=True)) + db.session.commit() + + self.assert_can_add_address(user=user, address='foobar@example.com') + self.assert_can_add_address(user=user, address='foobar@example.com', verified=True) + + self.assert_cannot_add_address(user=user, address='foo@example.com') + self.assert_can_add_address(user=user, address='FOO@example.com') + self.assert_cannot_add_address(user=user, address='bar@example.com') + self.assert_can_add_address(user=user, address='BAR@example.com') + + self.assert_cannot_add_address(user=user, address='foo@example.com', verified=True) + self.assert_can_add_address(user=user, address='FOO@example.com', verified=True) + self.assert_cannot_add_address(user=user, address='bar@example.com', verified=True) + self.assert_can_add_address(user=user, address='BAR@example.com', verified=True) + + self.assert_can_add_address(user=admin, address='foobar@example.com') + self.assert_can_add_address(user=admin, address='foobar@example.com', verified=True) + + self.assert_can_add_address(user=admin, address='foo@example.com') + self.assert_can_add_address(user=admin, address='FOO@example.com') + self.assert_can_add_address(user=admin, address='bar@example.com') + self.assert_can_add_address(user=admin, address='BAR@example.com') + + self.assert_can_add_address(user=admin, address='foo@example.com', verified=True) + self.assert_can_add_address(user=admin, address='FOO@example.com', verified=True) + self.assert_can_add_address(user=admin, address='bar@example.com', verified=True) + self.assert_can_add_address(user=admin, address='BAR@example.com', verified=True) + + def test_unique_constraints_strict(self): + FeatureFlag.unique_email_addresses.enable() + # The same user cannot add the same (normalized) address multiple times, + # and different users cannot have the same verified (normalized) address + user = self.get_user() + admin = self.get_admin() + db.session.add(UserEmail(user=user, address='foo@example.com')) + db.session.add(UserEmail(user=user, address='bar@example.com', verified=True)) + db.session.commit() + + self.assert_can_add_address(user=user, address='foobar@example.com') + self.assert_can_add_address(user=user, address='foobar@example.com', verified=True) + + self.assert_cannot_add_address(user=user, address='foo@example.com') + self.assert_cannot_add_address(user=user, address='FOO@example.com') + self.assert_cannot_add_address(user=user, address='bar@example.com') + self.assert_cannot_add_address(user=user, address='BAR@example.com') + + self.assert_cannot_add_address(user=user, address='foo@example.com', verified=True) + self.assert_cannot_add_address(user=user, address='FOO@example.com', verified=True) + self.assert_cannot_add_address(user=user, address='bar@example.com', verified=True) + self.assert_cannot_add_address(user=user, address='BAR@example.com', verified=True) + + self.assert_can_add_address(user=admin, address='foobar@example.com') + self.assert_can_add_address(user=admin, address='foobar@example.com', verified=True) + + self.assert_can_add_address(user=admin, address='foo@example.com') + self.assert_can_add_address(user=admin, address='FOO@example.com') + self.assert_can_add_address(user=admin, address='bar@example.com') + self.assert_can_add_address(user=admin, address='BAR@example.com') + + self.assert_can_add_address(user=admin, address='foo@example.com', verified=True) + self.assert_can_add_address(user=admin, address='FOO@example.com', verified=True) + self.assert_cannot_add_address(user=admin, address='bar@example.com', verified=True) + self.assert_cannot_add_address(user=admin, address='BAR@example.com', verified=True) + class TestGroupModel(UffdTestCase): def test_unix_gid_generation(self): self.app.config['GROUP_MIN_GID'] = 20000 diff --git a/tests/views/test_selfservice.py b/tests/views/test_selfservice.py index 8260c04c..5bf3e8f4 100644 --- a/tests/views/test_selfservice.py +++ b/tests/views/test_selfservice.py @@ -4,7 +4,7 @@ import re from flask import url_for, request from uffd.database import db -from uffd.models import PasswordToken, UserEmail, Role, RoleGroup, Service, ServiceUser +from uffd.models import PasswordToken, UserEmail, Role, RoleGroup, Service, ServiceUser, FeatureFlag from tests.utils import dump, UffdTestCase @@ -114,7 +114,7 @@ class TestSelfservice(UffdTestCase): email.verification_expires = datetime.datetime.utcnow() - datetime.timedelta(days=1) db.session.add(email) db.session.commit() - r = self.client.get(path=url_for('selfservice.verify_email', email_id=email.id, secret='invalidsecret'), follow_redirects=True) + r = self.client.get(path=url_for('selfservice.verify_email', email_id=email.id, secret=secret), follow_redirects=True) dump('selfservice_verify_email_expired', r) self.assertFalse(email.verified) @@ -137,6 +137,20 @@ class TestSelfservice(UffdTestCase): self.assertTrue(email.verified) self.assertEqual(self.get_user().primary_email, email) + def test_verify_email_duplicate_strict_uniqueness(self): + FeatureFlag.unique_email_addresses.enable() + db.session.commit() + self.login_as('user') + email = UserEmail(user=self.get_user(), address='admin@example.com') + secret = email.start_verification() + db.session.add(email) + db.session.commit() + email_id = email.id + r = self.client.get(path=url_for('selfservice.verify_email', email_id=email.id, secret=secret), follow_redirects=True) + dump('selfservice_verify_email_duplicate_strict_uniqueness', r) + email = UserEmail.query.get(email_id) + self.assertFalse(email.verified) + def test_retry_email_verification(self): self.login_as('user') email = UserEmail(user=self.get_user(), address='new@example.com') diff --git a/tests/views/test_signup.py b/tests/views/test_signup.py index 8242481e..72f80488 100644 --- a/tests/views/test_signup.py +++ b/tests/views/test_signup.py @@ -3,7 +3,7 @@ import datetime from flask import url_for, request from uffd.database import db -from uffd.models import Signup, Role, RoleGroup +from uffd.models import Signup, Role, RoleGroup, FeatureFlag from uffd.views.session import login_get_user from tests.utils import dump, UffdTestCase, db_flush @@ -26,7 +26,7 @@ class TestSignupViews(UffdTestCase): self.assertEqual(r.status_code, 200) self.assertEqual(Signup.query.filter_by(loginname='newuser').all(), []) r = self.client.post(path=url_for('signup.signup_submit'), follow_redirects=True, - data={'loginname': 'newuser', 'displayname': 'New User', 'mail': 'test@example.com', + data={'loginname': 'newuser', 'displayname': 'New User', 'mail': 'new@example.com', 'password1': 'notsecret', 'password2': 'notsecret'}) dump('test_signup_submit', r) self.assertEqual(r.status_code, 200) @@ -36,7 +36,7 @@ class TestSignupViews(UffdTestCase): signup = signups[0] self.assertEqual(signup.loginname, 'newuser') self.assertEqual(signup.displayname, 'New User') - self.assertEqual(signup.mail, 'test@example.com') + self.assertEqual(signup.mail, 'new@example.com') self.assertIn(signup.token, str(self.app.last_mail.get_content())) self.assertTrue(signup.password.verify('notsecret')) self.assertTrue(signup.validate()[0]) @@ -48,7 +48,7 @@ class TestSignupViews(UffdTestCase): self.assertEqual(r.status_code, 200) self.assertEqual(Signup.query.filter_by(loginname='newuser').all(), []) r = self.client.post(path=url_for('signup.signup_submit'), follow_redirects=True, - data={'loginname': 'newuser', 'displayname': 'New User', 'mail': 'test@example.com', + data={'loginname': 'newuser', 'displayname': 'New User', 'mail': 'new@example.com', 'password1': 'notsecret', 'password2': 'notsecret'}) dump('test_signup_submit_disabled', r) self.assertEqual(r.status_code, 200) @@ -57,7 +57,7 @@ class TestSignupViews(UffdTestCase): def test_signup_wrongpassword(self): r = self.client.post(path=url_for('signup.signup_submit'), follow_redirects=True, - data={'loginname': 'newuser', 'displayname': 'New User', 'mail': 'test@example.com', + data={'loginname': 'newuser', 'displayname': 'New User', 'mail': 'new@example.com', 'password1': 'notsecret', 'password2': 'notthesame'}) dump('test_signup_wrongpassword', r) self.assertEqual(r.status_code, 200) @@ -65,7 +65,7 @@ class TestSignupViews(UffdTestCase): def test_signup_invalid(self): r = self.client.post(path=url_for('signup.signup_submit'), follow_redirects=True, - data={'loginname': '', 'displayname': 'New User', 'mail': 'test@example.com', + data={'loginname': '', 'displayname': 'New User', 'mail': 'new@example.com', 'password1': 'notsecret', 'password2': 'notsecret'}) dump('test_signup_invalid', r) self.assertEqual(r.status_code, 200) @@ -74,7 +74,7 @@ class TestSignupViews(UffdTestCase): def test_signup_mailerror(self): self.app.config['MAIL_SKIP_SEND'] = 'fail' r = self.client.post(path=url_for('signup.signup_submit'), follow_redirects=True, - data={'loginname': 'newuser', 'displayname': 'New User', 'mail': 'test@example.com', + data={'loginname': 'newuser', 'displayname': 'New User', 'mail': 'new@example.com', 'password1': 'notsecret', 'password2': 'notsecret'}) dump('test_signup_mailerror', r) self.assertEqual(r.status_code, 200) @@ -93,7 +93,7 @@ class TestSignupViews(UffdTestCase): self.assertEqual(r.status_code, 200) self.app.last_mail = None r = self.client.post(path=url_for('signup.signup_submit'), follow_redirects=True, - data={'loginname': 'newuser', 'displayname': 'New User', 'mail': 'test@example.com', + data={'loginname': 'newuser', 'displayname': 'New User', 'mail': 'new@example.com', 'password1': 'notsecret', 'password2': 'notsecret'}) dump('test_signup_hostlimit', r) self.assertEqual(r.status_code, 200) @@ -103,12 +103,12 @@ class TestSignupViews(UffdTestCase): def test_signup_maillimit(self): for i in range(3): r = self.client.post(path=url_for('signup.signup_submit'), follow_redirects=True, - data={'loginname': 'newuser%d'%i, 'displayname': 'New User', 'mail': 'test@example.com', + data={'loginname': 'newuser%d'%i, 'displayname': 'New User', 'mail': 'new@example.com', 'password1': 'notsecret', 'password2': 'notsecret'}) self.assertEqual(r.status_code, 200) self.app.last_mail = None r = self.client.post(path=url_for('signup.signup_submit'), follow_redirects=True, - data={'loginname': 'newuser', 'displayname': 'New User', 'mail': 'test@example.com', + data={'loginname': 'newuser', 'displayname': 'New User', 'mail': 'new@example.com', 'password1': 'notsecret', 'password2': 'notsecret'}) dump('test_signup_maillimit', r) self.assertEqual(r.status_code, 200) @@ -158,7 +158,7 @@ class TestSignupViews(UffdTestCase): db.session.add(baserole) baserole.groups[self.get_access_group()] = RoleGroup() db.session.commit() - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret') signup = refetch_signup(signup) self.assertFalse(signup.completed) self.assertIsNone(login_get_user('newuser', 'notsecret')) @@ -175,7 +175,7 @@ class TestSignupViews(UffdTestCase): self.assertTrue(signup.completed) self.assertEqual(signup.user.loginname, 'newuser') self.assertEqual(signup.user.displayname, 'New User') - self.assertEqual(signup.user.primary_email.address, 'test@example.com') + self.assertEqual(signup.user.primary_email.address, 'new@example.com') self.assertIsNotNone(login_get_user('newuser', 'notsecret')) def test_confirm_loggedin(self): @@ -184,7 +184,7 @@ class TestSignupViews(UffdTestCase): baserole.groups[self.get_access_group()] = RoleGroup() db.session.commit() self.login_as('user') - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret') signup = refetch_signup(signup) self.assertFalse(signup.completed) self.assertIsNotNone(request.user) @@ -207,7 +207,7 @@ class TestSignupViews(UffdTestCase): self.assertEqual(r.status_code, 200) def test_confirm_expired(self): - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret') signup.created = datetime.datetime.utcnow() - datetime.timedelta(hours=49) signup = refetch_signup(signup) r = self.client.get(path=url_for('signup.signup_confirm', signup_id=signup.id, token=signup.token), follow_redirects=True) @@ -218,7 +218,7 @@ class TestSignupViews(UffdTestCase): self.assertEqual(r.status_code, 200) def test_confirm_completed(self): - signup = Signup(loginname=self.get_user().loginname, displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname=self.get_user().loginname, displayname='New User', mail='new@example.com', password='notsecret') signup.user = self.get_user() signup = refetch_signup(signup) self.assertTrue(signup.completed) @@ -230,7 +230,7 @@ class TestSignupViews(UffdTestCase): self.assertEqual(r.status_code, 200) def test_confirm_wrongpassword(self): - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret') signup = refetch_signup(signup) r = self.client.post(path=url_for('signup.signup_confirm_submit', signup_id=signup.id, token=signup.token), follow_redirects=True, data={'password': 'wrongpassword'}) dump('test_signup_confirm_wrongpassword', r) @@ -240,7 +240,7 @@ class TestSignupViews(UffdTestCase): def test_confirm_error(self): # finish returns None and error message (here: because the user already exists) - signup = Signup(loginname=self.get_user().loginname, displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname=self.get_user().loginname, displayname='New User', mail='new@example.com', password='notsecret') signup = refetch_signup(signup) r = self.client.post(path=url_for('signup.signup_confirm_submit', signup_id=signup.id, token=signup.token), follow_redirects=True, data={'password': 'notsecret'}) dump('test_signup_confirm_error', r) @@ -248,13 +248,28 @@ class TestSignupViews(UffdTestCase): self.assertEqual(r.status_code, 200) self.assertFalse(signup.completed) + def test_confirm_error_email_uniqueness(self): + FeatureFlag.unique_email_addresses.enable() + db.session.commit() + # finish returns None and error message (here: because the email address already exists) + # This case is interesting, because the error also invalidates the ORM session + signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret') + db.session.add(signup) + db.session.commit() + signup_id = signup.id + r = self.client.post(path=url_for('signup.signup_confirm_submit', signup_id=signup.id, token=signup.token), follow_redirects=True, data={'password': 'notsecret'}) + dump('test_signup_confirm_error_email_uniqueness', r) + self.assertEqual(r.status_code, 200) + signup = Signup.query.get(signup_id) + self.assertFalse(signup.completed) + def test_confirm_hostlimit(self): for i in range(20): - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret') signup = refetch_signup(signup) r = self.client.post(path=url_for('signup.signup_confirm_submit', signup_id=signup.id, token=signup.token), follow_redirects=True, data={'password': 'wrongpassword%d'%i}) self.assertEqual(r.status_code, 200) - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret') signup = refetch_signup(signup) self.assertFalse(signup.completed) r = self.client.post(path=url_for('signup.signup_confirm_submit', signup_id=signup.id, token=signup.token), follow_redirects=True, data={'password': 'notsecret'}) @@ -263,7 +278,7 @@ class TestSignupViews(UffdTestCase): self.assertFalse(signup.completed) def test_confirm_confirmlimit(self): - signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret') + signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret') signup = refetch_signup(signup) self.assertFalse(signup.completed) for i in range(5): diff --git a/tests/views/test_user.py b/tests/views/test_user.py index 308167bf..e51d305f 100644 --- a/tests/views/test_user.py +++ b/tests/views/test_user.py @@ -1,13 +1,14 @@ from flask import url_for from uffd.database import db -from uffd.models import User, UserEmail, Group, Role, Service, ServiceUser +from uffd.models import User, UserEmail, Group, Role, Service, ServiceUser, FeatureFlag from tests.utils import dump, UffdTestCase class TestUserViews(UffdTestCase): def setUp(self): super().setUp() + self.app.last_mail = None self.login_as('admin') def test_index(self): @@ -27,7 +28,7 @@ class TestUserViews(UffdTestCase): dump('user_new', r) self.assertEqual(r.status_code, 200) self.assertIsNone(User.query.filter_by(loginname='newuser').one_or_none()) - r = self.client.post(path=url_for('user.update'), + r = self.client.post(path=url_for('user.create'), data={'loginname': 'newuser', 'email': 'newuser@example.com', 'displayname': 'New User', f'role-{role1_id}': '1', 'password': 'newpassword'}, follow_redirects=True) dump('user_new_submit', r) @@ -39,11 +40,12 @@ class TestUserViews(UffdTestCase): self.assertEqual(user_.loginname, 'newuser') self.assertEqual(user_.displayname, 'New User') self.assertEqual(user_.primary_email.address, 'newuser@example.com') + self.assertFalse(user_.password) self.assertGreaterEqual(user_.unix_uid, self.app.config['USER_MIN_UID']) self.assertLessEqual(user_.unix_uid, self.app.config['USER_MAX_UID']) role1 = Role(name='role1') self.assertEqual(roles, ['base', 'role1']) - # TODO: confirm Mail is send, login not yet possible + self.assertIsNotNone(self.app.last_mail) def test_new_service(self): db.session.add(Role(name='base', is_default=True)) @@ -57,7 +59,7 @@ class TestUserViews(UffdTestCase): dump('user_new_service', r) self.assertEqual(r.status_code, 200) self.assertIsNone(User.query.filter_by(loginname='newuser').one_or_none()) - r = self.client.post(path=url_for('user.update'), + r = self.client.post(path=url_for('user.create'), data={'loginname': 'newuser', 'email': 'newuser@example.com', 'displayname': 'New User', f'role-{role1_id}': '1', 'password': 'newpassword', 'serviceaccount': '1'}, follow_redirects=True) dump('user_new_submit', r) @@ -70,12 +72,13 @@ class TestUserViews(UffdTestCase): self.assertEqual(user.displayname, 'New User') self.assertEqual(user.primary_email.address, 'newuser@example.com') self.assertTrue(user.unix_uid) + self.assertFalse(user.password) role1 = Role(name='role1') self.assertEqual(roles, ['role1']) - # TODO: confirm Mail is send, login not yet possible + self.assertIsNone(self.app.last_mail) def test_new_invalid_loginname(self): - r = self.client.post(path=url_for('user.update'), + r = self.client.post(path=url_for('user.create'), data={'loginname': '!newuser', 'email': 'newuser@example.com', 'displayname': 'New User', 'password': 'newpassword'}, follow_redirects=True) dump('user_new_invalid_loginname', r) @@ -83,23 +86,42 @@ class TestUserViews(UffdTestCase): self.assertIsNone(User.query.filter_by(loginname='newuser').one_or_none()) def test_new_empty_loginname(self): - r = self.client.post(path=url_for('user.update'), + r = self.client.post(path=url_for('user.create'), data={'loginname': '', 'email': 'newuser@example.com', 'displayname': 'New User', 'password': 'newpassword'}, follow_redirects=True) dump('user_new_empty_loginname', r) self.assertEqual(r.status_code, 200) self.assertIsNone(User.query.filter_by(loginname='newuser').one_or_none()) + def test_new_conflicting_loginname(self): + self.assertEqual(User.query.filter_by(loginname='testuser').count(), 1) + r = self.client.post(path=url_for('user.create'), + data={'loginname': 'testuser', 'email': 'newuser@example.com', 'displayname': 'New User', + 'password': 'newpassword'}, follow_redirects=True) + dump('user_new_conflicting_loginname', r) + self.assertEqual(r.status_code, 200) + self.assertEqual(User.query.filter_by(loginname='testuser').count(), 1) + def test_new_empty_email(self): - r = self.client.post(path=url_for('user.update'), + r = self.client.post(path=url_for('user.create'), data={'loginname': 'newuser', 'email': '', 'displayname': 'New User', 'password': 'newpassword'}, follow_redirects=True) dump('user_new_empty_email', r) self.assertEqual(r.status_code, 200) self.assertIsNone(User.query.filter_by(loginname='newuser').one_or_none()) + def test_new_conflicting_email(self): + FeatureFlag.unique_email_addresses.enable() + db.session.commit() + r = self.client.post(path=url_for('user.create'), + data={'loginname': 'newuser', 'email': 'test@example.com', 'displayname': 'New User', + 'password': 'newpassword'}, follow_redirects=True) + dump('user_new_conflicting_email', r) + self.assertEqual(r.status_code, 200) + self.assertIsNone(User.query.filter_by(loginname='newuser').one_or_none()) + def test_new_invalid_display_name(self): - r = self.client.post(path=url_for('user.update'), + r = self.client.post(path=url_for('user.create'), data={'loginname': 'newuser', 'email': 'newuser@example.com', 'displayname': 'A'*200, 'password': 'newpassword'}, follow_redirects=True) dump('user_new_invalid_display_name', r) @@ -235,6 +257,78 @@ class TestUserViews(UffdTestCase): } ) + def test_update_email_conflict(self): + user = self.get_user() + user_id = user.id + email_id = user.primary_email.id + email_address = user.primary_email.address + r = self.client.post(path=url_for('user.update', id=user.id), + data={'loginname': 'testuser', + f'email-{email_id}-present': '1', + f'newemail-1-address': user.primary_email.address}, + follow_redirects=True) + dump('user_update_email_conflict', r) + self.assertEqual(r.status_code, 200) + self.assertEqual(UserEmail.query.filter_by(user_id=user_id).count(), 1) + + def test_update_email_strict_uniqueness(self): + FeatureFlag.unique_email_addresses.enable() + db.session.commit() + user = self.get_user() + email = UserEmail(user=user, address='foo@example.com') + service1 = Service(name='service1', enable_email_preferences=True) + service2 = Service(name='service2', enable_email_preferences=True) + db.session.add_all([service1, service2]) + db.session.commit() + email1_id = user.primary_email.id + email2_id = email.id + service1_id = service1.id + service2_id = service2.id + r = self.client.post(path=url_for('user.update', id=user.id), + data={'loginname': 'testuser', + f'email-{email1_id}-present': '1', + f'email-{email2_id}-present': '1', + f'email-{email2_id}-verified': '1', + f'newemail-1-address': 'new1@example.com', + f'newemail-2-address': 'new2@example.com', f'newemail-2-verified': '1', + 'primary_email': email2_id, 'recovery_email': email1_id, + f'service_{service1_id}_email': 'primary', + f'service_{service2_id}_email': email2_id, + 'displayname': 'Test User', 'password': ''}, + follow_redirects=True) + dump('user_update_email_strict_uniqueness', r) + self.assertEqual(r.status_code, 200) + user = self.get_user() + self.assertEqual(user.primary_email.id, email2_id) + self.assertEqual(user.recovery_email.id, email1_id) + self.assertEqual(ServiceUser.query.get((service1.id, user.id)).service_email, None) + self.assertEqual(ServiceUser.query.get((service2.id, user.id)).service_email.id, email2_id) + self.assertEqual( + {email.address: email.verified for email in user.all_emails}, + { + 'test@example.com': True, + 'foo@example.com': True, + 'new1@example.com': False, + 'new2@example.com': True, + } + ) + + def test_update_email_strict_uniqueness_conflict(self): + FeatureFlag.unique_email_addresses.enable() + db.session.commit() + user = self.get_user() + user_id = user.id + email_id = user.primary_email.id + email_address = user.primary_email.address + r = self.client.post(path=url_for('user.update', id=user.id), + data={'loginname': 'testuser', + f'email-{email_id}-present': '1', + f'newemail-1-address': user.primary_email.address}, + follow_redirects=True) + dump('user_update_email_strict_uniqueness_conflict', r) + self.assertEqual(r.status_code, 200) + self.assertEqual(UserEmail.query.filter_by(user_id=user_id).count(), 1) + def test_update_invalid_display_name(self): user_unupdated = self.get_user() email_id = str(user_unupdated.primary_email.id) diff --git a/uffd/commands/__init__.py b/uffd/commands/__init__.py index e3ad7436..4acc9607 100644 --- a/uffd/commands/__init__.py +++ b/uffd/commands/__init__.py @@ -5,6 +5,7 @@ from .profile import profile_command from .gendevcert import gendevcert_command from .cleanup import cleanup_command from .roles_update_all import roles_update_all_command +from .unique_email_addresses import unique_email_addresses_command def init_app(app): app.cli.add_command(user_command) @@ -14,3 +15,4 @@ def init_app(app): app.cli.add_command(profile_command) app.cli.add_command(cleanup_command) app.cli.add_command(roles_update_all_command) + app.cli.add_command(unique_email_addresses_command) diff --git a/uffd/commands/group.py b/uffd/commands/group.py index a98e23b3..43a6519d 100644 --- a/uffd/commands/group.py +++ b/uffd/commands/group.py @@ -38,8 +38,9 @@ def create(name, description): try: db.session.add(group) db.session.commit() - except IntegrityError as ex: - raise click.ClickException(f'Group creation failed: {ex}') + except IntegrityError: + # pylint: disable=raise-missing-from + raise click.ClickException(f'A group with name "{name}" already exists') @group_command.command(help='Update group attributes') @click.argument('name') diff --git a/uffd/commands/role.py b/uffd/commands/role.py index 584aaa0d..035bdf31 100644 --- a/uffd/commands/role.py +++ b/uffd/commands/role.py @@ -89,8 +89,9 @@ def create(name, description, default, moderator_group, add_group, add_role): db.session.add(role) role.update_member_groups() db.session.commit() - except IntegrityError as ex: - raise click.ClickException(f'Role creation failed: {ex}') + except IntegrityError: + # pylint: disable=raise-missing-from + raise click.ClickException(f'A role with name "{name}" already exists') @role_command.command(help='Update role attributes') @click.argument('name') diff --git a/uffd/commands/unique_email_addresses.py b/uffd/commands/unique_email_addresses.py new file mode 100644 index 00000000..62ac096d --- /dev/null +++ b/uffd/commands/unique_email_addresses.py @@ -0,0 +1,67 @@ +import click +from flask.cli import with_appcontext +from sqlalchemy.exc import IntegrityError + +from uffd.database import db +from uffd.models import User, UserEmail, FeatureFlag + +# pylint completely fails to understand SQLAlchemy's query functions +# pylint: disable=no-member + +@click.group('unique-email-addresses', help='Enable/disable e-mail address uniqueness checks') +def unique_email_addresses_command(): + pass + +@unique_email_addresses_command.command('enable') +@with_appcontext +def enable_unique_email_addresses_command(): + if FeatureFlag.unique_email_addresses: + raise click.ClickException('Uniqueness checks for e-mail addresses are already enabled') + query = db.select([UserEmail.address_normalized, UserEmail.user_id])\ + .group_by(UserEmail.address_normalized, UserEmail.user_id)\ + .having(db.func.count(UserEmail.id.distinct()) > 1) + for address_normalized, user_id in db.session.execute(query).fetchall(): + user = User.query.get(user_id) + user_emails = UserEmail.query.filter_by(address_normalized=address_normalized, user_id=user_id) + click.echo(f'User "{user.loginname}" has the same e-mail address multiple times:', err=True) + for user_email in user_emails: + if user_email.verified: + click.echo(f'- {user_email.address}', err=True) + else: + click.echo(f'- {user_email.address} (unverified)', err=True) + click.echo() + query = db.select([UserEmail.address_normalized, UserEmail.address])\ + .where(UserEmail.verified)\ + .group_by(UserEmail.address_normalized)\ + .having(db.func.count(UserEmail.id.distinct()) > 1) + for address_normalized, address in db.session.execute(query).fetchall(): + click.echo(f'E-mail address "{address}" is used by multiple users:', err=True) + user_emails = UserEmail.query.filter_by(address_normalized=address_normalized, verified=True) + for user_email in user_emails: + if user_email.address != address: + click.echo(f'- {user_email.user.loginname} ({user_email.address})', err=True) + else: + click.echo(f'- {user_email.user.loginname}', err=True) + click.echo() + try: + FeatureFlag.unique_email_addresses.enable() + except IntegrityError: + # pylint: disable=raise-missing-from + raise click.ClickException('''Some existing e-mail addresses violate uniqueness checks + +You need to fix this manually in the admin interface. Then run this command +again to continue.''') + db.session.commit() + click.echo('Uniqueness checks for e-mail addresses enabled') + +@unique_email_addresses_command.command('disable') +@with_appcontext +def disable_unique_email_addresses_command(): + if not FeatureFlag.unique_email_addresses: + raise click.ClickException('Uniqueness checks for e-mail addresses are already disabled') + click.echo('''Please note that the option to disable email address uniqueness checks will +be remove in uffd v3. +''', err=True) + FeatureFlag.unique_email_addresses.disable() + db.session.commit() + click.echo('Uniqueness checks for e-mail addresses disabled') diff --git a/uffd/commands/user.py b/uffd/commands/user.py index cf3dd6e0..2db24b3f 100644 --- a/uffd/commands/user.py +++ b/uffd/commands/user.py @@ -70,15 +70,15 @@ def create(loginname, mail, displayname, service, password, prompt_password, add if displayname is None: displayname = loginname user = User(is_service_user=service) - user = User(is_service_user=service) if not user.set_loginname(loginname, ignore_blocklist=True): raise click.ClickException('Invalid loginname') try: db.session.add(user) update_attrs(user, mail, displayname, password, prompt_password, add_role=add_role) db.session.commit() - except IntegrityError as ex: - raise click.ClickException(f'User creation failed: {ex}') + except IntegrityError: + # pylint: disable=raise-missing-from + raise click.ClickException('Login name or e-mail address is already in use') @user_command.command(help='Update user attributes and roles') @click.argument('loginname') @@ -94,8 +94,12 @@ def update(loginname, mail, displayname, password, prompt_password, clear_roles, user = User.query.filter_by(loginname=loginname).one_or_none() if user is None: raise click.ClickException(f'User {loginname} not found') - update_attrs(user, mail, displayname, password, prompt_password, clear_roles, add_role, remove_role) - db.session.commit() + try: + update_attrs(user, mail, displayname, password, prompt_password, clear_roles, add_role, remove_role) + db.session.commit() + except IntegrityError: + # pylint: disable=raise-missing-from + raise click.ClickException('E-mail address is already in use') @user_command.command(help='Delete user') @click.argument('loginname') diff --git a/uffd/migrations/versions/468995a9c9ee_unique_email_addresses.py b/uffd/migrations/versions/468995a9c9ee_unique_email_addresses.py new file mode 100644 index 00000000..2c776195 --- /dev/null +++ b/uffd/migrations/versions/468995a9c9ee_unique_email_addresses.py @@ -0,0 +1,102 @@ +"""Unique email addresses + +Revision ID: 468995a9c9ee +Revises: 2b68f688bec1 +Create Date: 2022-10-21 01:25:01.469670 + +""" +import unicodedata + +from alembic import op +import sqlalchemy as sa + +revision = '468995a9c9ee' +down_revision = '2b68f688bec1' +branch_labels = None +depends_on = None + +def normalize_address(value): + return unicodedata.normalize('NFKC', value).lower().strip() + +def iter_rows_paged(table, pk='id', limit=1000): + conn = op.get_bind() + pk_column = getattr(table.c, pk) + last_pk = None + while True: + expr = table.select().order_by(pk_column).limit(limit) + if last_pk is not None: + expr = expr.where(pk_column > last_pk) + result = conn.execute(expr) + pk_index = list(result.keys()).index(pk) + rows = result.fetchall() + if not rows: + break + yield from rows + last_pk = rows[-1][pk_index] + +def upgrade(): + with op.batch_alter_table('user_email', schema=None) as batch_op: + batch_op.add_column(sa.Column('address_normalized', sa.String(length=128), nullable=True)) + batch_op.add_column(sa.Column('enable_strict_constraints', sa.Boolean(), nullable=True)) + batch_op.alter_column('verified', existing_type=sa.Boolean(), nullable=True) + meta = sa.MetaData(bind=op.get_bind()) + user_email_table = sa.Table('user_email', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('address', sa.String(length=128), nullable=False), + sa.Column('address_normalized', sa.String(length=128), nullable=True), + sa.Column('enable_strict_constraints', sa.Boolean(), nullable=True), + sa.Column('verified', sa.Boolean(), nullable=True), + sa.Column('verification_legacy_id', sa.Integer(), nullable=True), + sa.Column('verification_secret', sa.Text(), nullable=True), + sa.Column('verification_expires', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_user_email_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_user_email')), + sa.UniqueConstraint('user_id', 'address', name='uq_user_email_user_id_address') + ) + for row in iter_rows_paged(user_email_table): + id = row[0] + address = row[2] + verified = row[5] + op.execute(user_email_table.update()\ + .where(user_email_table.c.id == id)\ + .values( + address_normalized=normalize_address(address), + verified=(True if verified else None) + ) + ) + with op.batch_alter_table('user_email', copy_from=user_email_table) as batch_op: + batch_op.alter_column('address_normalized', existing_type=sa.String(length=128), nullable=False) + batch_op.create_unique_constraint('uq_user_email_address_normalized_verified', ['address_normalized', 'verified', 'enable_strict_constraints']) + batch_op.create_unique_constraint('uq_user_email_user_id_address_normalized', ['user_id', 'address_normalized', 'enable_strict_constraints']) + op.create_table('feature_flag', + sa.Column('name', sa.String(32), nullable=False), + sa.PrimaryKeyConstraint('name', name=op.f('pk_feature_flag')), + ) + +def downgrade(): + meta = sa.MetaData(bind=op.get_bind()) + user_email_table = sa.Table('user_email', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('address', sa.String(length=128), nullable=False), + sa.Column('address_normalized', sa.String(length=128), nullable=False), + sa.Column('enable_strict_constraints', sa.Boolean(), nullable=True), + sa.Column('verified', sa.Boolean(), nullable=True), + sa.Column('verification_legacy_id', sa.Integer(), nullable=True), + sa.Column('verification_secret', sa.Text(), nullable=True), + sa.Column('verification_expires', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_user_email_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_user_email')), + sa.UniqueConstraint('user_id', 'address', name='uq_user_email_user_id_address'), + sa.UniqueConstraint('address_normalized', 'verified', 'enable_strict_constraints', name='uq_user_email_address_normalized_verified'), + sa.UniqueConstraint('user_id', 'address_normalized', 'enable_strict_constraints', name='uq_user_email_user_id_address_normalized') + ) + op.execute(user_email_table.update().where(user_email_table.c.verified == None).values(verified=False)) + with op.batch_alter_table('user_email', copy_from=user_email_table) as batch_op: + batch_op.drop_constraint('uq_user_email_user_id_address_normalized', type_='unique') + batch_op.drop_constraint('uq_user_email_address_normalized_verified', type_='unique') + batch_op.alter_column('verified', existing_type=sa.Boolean(), nullable=False) + batch_op.drop_column('enable_strict_constraints') + batch_op.drop_column('address_normalized') + op.drop_table('feature_flag') diff --git a/uffd/models/__init__.py b/uffd/models/__init__.py index 1644e472..ad417982 100644 --- a/uffd/models/__init__.py +++ b/uffd/models/__init__.py @@ -10,6 +10,7 @@ from .session import DeviceLoginType, DeviceLoginInitiation, DeviceLoginConfirma from .signup import Signup from .user import User, UserEmail, Group from .ratelimit import RatelimitEvent, Ratelimit, HostRatelimit, host_ratelimit, format_delay +from .misc import FeatureFlag __all__ = [ 'APIClient', @@ -24,4 +25,5 @@ __all__ = [ 'Signup', 'User', 'UserEmail', 'Group', 'RatelimitEvent', 'Ratelimit', 'HostRatelimit', 'host_ratelimit', 'format_delay', + 'FeatureFlag', ] diff --git a/uffd/models/misc.py b/uffd/models/misc.py new file mode 100644 index 00000000..3fdf954b --- /dev/null +++ b/uffd/models/misc.py @@ -0,0 +1,41 @@ +from uffd.database import db + +# pylint completely fails to understand SQLAlchemy's query functions +# pylint: disable=no-member + +feature_flag_table = db.Table('feature_flag', + db.Column('name', db.String(32), primary_key=True), +) + +class FeatureFlag: + def __init__(self, name): + self.name = name + self.enable_hooks = [] + self.disable_hooks = [] + + @property + def expr(self): + return db.exists().where(feature_flag_table.c.name == self.name) + + def __bool__(self): + return db.session.execute(db.select([self.expr])).scalar() + + def enable_hook(self, func): + self.enable_hooks.append(func) + return func + + def enable(self): + db.session.execute(db.insert(feature_flag_table).values(name=self.name)) + for func in self.enable_hooks: + func() + + def disable_hook(self, func): + self.disable_hooks.append(func) + return func + + def disable(self): + db.session.execute(db.delete(feature_flag_table).where(feature_flag_table.c.name == self.name)) + for func in self.disable_hooks: + func() + +FeatureFlag.unique_email_addresses = FeatureFlag('unique-email-addresses') diff --git a/uffd/models/signup.py b/uffd/models/signup.py index e56f565e..2df2fba0 100644 --- a/uffd/models/signup.py +++ b/uffd/models/signup.py @@ -2,6 +2,7 @@ import datetime from flask_babel import gettext as _ from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import relationship, backref from sqlalchemy.ext.hybrid import hybrid_property @@ -104,8 +105,14 @@ class Signup(db.Model): return None, _('Wrong password') if User.query.filter_by(loginname=self.loginname).all(): return None, _('A user with this login name already exists') + # Flush to make sure the flush below does not catch unrelated errors + db.session.flush() user = User(loginname=self.loginname, displayname=self.displayname, primary_email_address=self.mail, password=self.password) db.session.add(user) + try: + db.session.flush() + except IntegrityError: + return None, _('Login name or e-mail address is already in use') user.update_groups() # pylint: disable=no-member self.user = user self.loginname = None diff --git a/uffd/models/user.py b/uffd/models/user.py index 374afef6..481cac3c 100644 --- a/uffd/models/user.py +++ b/uffd/models/user.py @@ -1,6 +1,7 @@ import string import re import datetime +import unicodedata from flask import current_app, escape from flask_babel import lazy_gettext @@ -12,6 +13,7 @@ from uffd.database import db from uffd.remailer import remailer from uffd.utils import token_urlfriendly from uffd.password_hash import PasswordHashAttribute, LowEntropyPasswordHash, HighEntropyPasswordHash +from .misc import FeatureFlag # pylint: disable=E1101 user_groups = db.Table('user_groups', @@ -171,29 +173,67 @@ class UserEmail(db.Model): raise ValueError('UserEmail.user cannot be changed once set') return value + @classmethod + def normalize_address(cls, value): + return unicodedata.normalize('NFKC', value).lower().strip() + address = Column(String(128), nullable=False) + address_normalized = Column(String(128), nullable=False) @validates('address') def validate_address(self, key, value): # pylint: disable=unused-argument if self.address is not None and self.address != value: raise ValueError('UserEmail.address cannot be changed once set') + self.address_normalized = self.normalize_address(value) return value - verified = Column(Boolean(), default=False, nullable=False) + # True or None/NULL (not False, see constraints below) + _verified = Column('verified', Boolean(), nullable=True) + + @hybrid_property + def verified(self): + # pylint: disable=singleton-comparison + return self._verified != None - @validates('verified') - def validate_verified(self, key, value): # pylint: disable=unused-argument - if self.verified and not value: + @verified.setter + def verified(self, value): + if self._verified and not value: raise ValueError('UserEmail cannot be unverified once verified') - return value + self._verified = True if value else None verification_legacy_id = Column(Integer()) # id of old MailToken _verification_secret = Column('verification_secret', Text()) verification_secret = PasswordHashAttribute('_verification_secret', HighEntropyPasswordHash) verification_expires = Column(DateTime) + # Until uffd v3, we make the stricter unique constraints optional, by having + # enable_strict_constraints act as a switch to enable/disable the constraints + # on a per-row basis. + # True or None/NULL if disabled (not False, see constraints below) + enable_strict_constraints = Column( + Boolean(), + nullable=True, + default=db.select([db.case([(FeatureFlag.unique_email_addresses.expr, True)], else_=None)]) + ) + + # The unique constraints rely on the common interpretation of SQL92, that if + # any column in a unique constraint is NULL, the unique constraint essentially + # does not apply to the row. This is how SQLite, MySQL/MariaDB, PostgreSQL and + # other common databases behave. A few others like Microsoft SQL Server do not + # follow this, but we don't support them anyway. __table_args__ = ( - db.UniqueConstraint('user_id', 'address', name='uq_user_email_user_id_address'), + # A user cannot have the same address more than once, regardless of verification status + db.UniqueConstraint('user_id', 'address', name='uq_user_email_user_id_address'), # Legacy, to be removed in v3 + # Same unique constraint as uq_user_email_user_id_address, but with + # address_normalized instead of address. Only active if + # enable_strict_constraints is not NULL. + db.UniqueConstraint('user_id', 'address_normalized', 'enable_strict_constraints', + name='uq_user_email_user_id_address_normalized'), + # The same verified address can only exist once. Only active if + # enable_strict_constraints is not NULL. Unverified addresses are ignored, + # since verified is NULL in that case. + db.UniqueConstraint('address_normalized', 'verified', 'enable_strict_constraints', + name='uq_user_email_address_normalized_verified'), ) def set_address(self, value): @@ -232,6 +272,14 @@ class UserEmail(db.Model): self.verified = True return True +@FeatureFlag.unique_email_addresses.enable_hook +def enable_unique_email_addresses(): + UserEmail.query.update({UserEmail.enable_strict_constraints: True}) + +@FeatureFlag.unique_email_addresses.disable_hook +def disable_unique_email_addresses(): + UserEmail.query.update({UserEmail.enable_strict_constraints: None}) + def next_id_expr(column, min_value, max_value): # db.func.max(column) + 1: highest used value in range + 1, NULL if no values in range # db.func.min(..., max_value): clip to range diff --git a/uffd/templates/user/show.html b/uffd/templates/user/show.html index 30a81738..bc22d8e5 100644 --- a/uffd/templates/user/show.html +++ b/uffd/templates/user/show.html @@ -16,7 +16,11 @@ {% endmacro %} {% block body %} +{% if user.id %} <form action="{{ url_for("user.update", id=user.id) }}" method="POST"> +{% else %} +<form action="{{ url_for("user.create") }}" method="POST"> +{% endif %} <div class="align-self-center"> <div class="float-sm-right pb-2"> <button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> {{_("Save")}}</button> diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo index 52af6cfad16ce638fbfb8e1ea2d018fd317566d8..e20677fe03a9318c965249d14437600f175bd9cd 100644 GIT binary patch delta 6835 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 Nje%LXSu%gOHUN9(rR4ws delta 6513 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 diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po index 39b09746..4088baad 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: 2022-10-20 17:36+0200\n" +"POT-Creation-Date: 2022-10-25 22:00+0200\n" "PO-Revision-Date: 2021-05-25 21:18+0200\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language: de\n" @@ -30,7 +30,7 @@ msgstr "Einladungslink weist keine Rollen zu" msgid "Invite link does not grant any new roles" msgstr "Einladungslink weist keine neuen Rollen zu" -#: uffd/models/invite.py:91 uffd/models/signup.py:115 +#: uffd/models/invite.py:91 uffd/models/signup.py:122 #: uffd/templates/mfa/setup.html:225 msgid "Success" msgstr "Erfolgreich" @@ -61,40 +61,44 @@ msgstr "eine Stunde" msgid "%(hours)d hours" msgstr "%(hours)d Stunden" -#: uffd/models/signup.py:77 uffd/models/signup.py:102 +#: uffd/models/signup.py:78 uffd/models/signup.py:103 msgid "Invalid signup request" msgstr "Ungültiger Account-Registrierungs-Link" -#: uffd/models/signup.py:79 +#: uffd/models/signup.py:80 msgid "Login name is invalid" msgstr "Anmeldename ist ungültig" -#: uffd/models/signup.py:81 +#: uffd/models/signup.py:82 msgid "Display name is invalid" msgstr "Anzeigename ist ungültig" -#: uffd/models/signup.py:83 uffd/views/selfservice.py:112 uffd/views/user.py:54 -#: uffd/views/user.py:73 +#: uffd/models/signup.py:84 uffd/views/selfservice.py:112 uffd/views/user.py:51 +#: uffd/views/user.py:99 msgid "E-Mail address is invalid" msgstr "Ungültige E-Mail-Adresse" -#: uffd/models/signup.py:85 uffd/views/selfservice.py:49 +#: uffd/models/signup.py:86 uffd/views/selfservice.py:49 msgid "Invalid password" msgstr "Passwort ungültig" -#: uffd/models/signup.py:87 uffd/models/signup.py:106 +#: uffd/models/signup.py:88 uffd/models/signup.py:107 msgid "A user with this login name already exists" msgstr "Ein Account mit diesem Anmeldenamen existiert bereits" -#: uffd/models/signup.py:88 +#: uffd/models/signup.py:89 msgid "Valid" msgstr "Gültig" -#: uffd/models/signup.py:104 uffd/views/signup.py:104 +#: uffd/models/signup.py:105 uffd/views/signup.py:104 msgid "Wrong password" msgstr "Falsches Passwort" -#: uffd/models/user.py:35 +#: uffd/models/signup.py:115 uffd/views/user.py:62 +msgid "Login name or e-mail address is already in use" +msgstr "Der Anmeldename oder die E-Mail-Adresse wird bereits verwendet" + +#: uffd/models/user.py:37 #, python-format msgid "" "At least %(minlen)d and at most %(maxlen)d characters. Only letters, " @@ -140,8 +144,8 @@ msgstr "Über uffd" #: uffd/templates/group/list.html:8 uffd/templates/invite/list.html:6 #: uffd/templates/mail/list.html:8 uffd/templates/role/list.html:8 -#: uffd/templates/service/index.html:8 uffd/templates/service/show.html:73 -#: uffd/templates/service/show.html:101 uffd/templates/user/list.html:8 +#: uffd/templates/service/index.html:8 uffd/templates/service/show.html:75 +#: uffd/templates/service/show.html:103 uffd/templates/user/list.html:8 msgid "New" msgstr "Neu" @@ -158,8 +162,8 @@ msgstr "GID" #: uffd/templates/rolemod/list.html:9 uffd/templates/rolemod/show.html:44 #: uffd/templates/selfservice/self.html:189 #: uffd/templates/service/index.html:14 uffd/templates/service/show.html:20 -#: uffd/templates/service/show.html:107 uffd/templates/user/show.html:189 -#: uffd/templates/user/show.html:221 +#: uffd/templates/service/show.html:109 uffd/templates/user/show.html:193 +#: uffd/templates/user/show.html:225 msgid "Name" msgstr "Name" @@ -167,14 +171,14 @@ msgstr "Name" #: uffd/templates/invite/new.html:36 uffd/templates/role/list.html:15 #: uffd/templates/role/show.html:48 uffd/templates/rolemod/list.html:10 #: uffd/templates/rolemod/show.html:26 uffd/templates/selfservice/self.html:190 -#: uffd/templates/user/show.html:190 uffd/templates/user/show.html:222 +#: uffd/templates/user/show.html:194 uffd/templates/user/show.html:226 msgid "Description" msgstr "Beschreibung" #: uffd/templates/group/show.html:8 uffd/templates/mail/show.html:27 #: uffd/templates/role/show.html:13 uffd/templates/rolemod/show.html:8 #: uffd/templates/service/api.html:15 uffd/templates/service/oauth2.html:15 -#: uffd/templates/service/show.html:16 uffd/templates/user/show.html:22 +#: uffd/templates/service/show.html:16 uffd/templates/user/show.html:26 msgid "Save" msgstr "Speichern" @@ -185,7 +189,7 @@ msgstr "Speichern" #: uffd/templates/service/show.html:10 #: uffd/templates/session/deviceauth.html:39 #: uffd/templates/session/deviceauth.html:49 -#: uffd/templates/session/devicelogin.html:29 uffd/templates/user/show.html:23 +#: uffd/templates/session/devicelogin.html:29 uffd/templates/user/show.html:27 msgid "Cancel" msgstr "Abbrechen" @@ -193,7 +197,7 @@ msgstr "Abbrechen" #: uffd/templates/role/show.html:21 uffd/templates/selfservice/self.html:61 #: uffd/templates/selfservice/self.html:204 uffd/templates/service/api.html:11 #: uffd/templates/service/oauth2.html:11 uffd/templates/service/show.html:12 -#: uffd/templates/user/show.html:25 uffd/templates/user/show.html:177 +#: uffd/templates/user/show.html:29 uffd/templates/user/show.html:181 msgid "Are you sure?" msgstr "Wirklich fortfahren?" @@ -203,8 +207,8 @@ msgstr "Wirklich fortfahren?" #: uffd/templates/role/show.html:21 uffd/templates/role/show.html:24 #: uffd/templates/selfservice/self.html:62 uffd/templates/service/api.html:12 #: uffd/templates/service/oauth2.html:12 uffd/templates/service/show.html:13 -#: uffd/templates/user/show.html:25 uffd/templates/user/show.html:27 -#: uffd/templates/user/show.html:100 +#: uffd/templates/user/show.html:29 uffd/templates/user/show.html:31 +#: uffd/templates/user/show.html:104 msgid "Delete" msgstr "Löschen" @@ -234,7 +238,7 @@ msgid "Created by" msgstr "Erstellt durch" #: uffd/templates/invite/list.html:14 uffd/templates/service/api.html:34 -#: uffd/templates/service/show.html:108 +#: uffd/templates/service/show.html:110 msgid "Permissions" msgstr "Berechtigungen" @@ -262,7 +266,7 @@ msgstr "Account-Registrierung" msgid "user signups" msgstr "Account-Registrierungen" -#: uffd/templates/invite/list.html:49 uffd/templates/user/show.html:174 +#: uffd/templates/invite/list.html:49 uffd/templates/user/show.html:178 msgid "Disabled" msgstr "Deaktiviert" @@ -456,7 +460,7 @@ msgid "One address per line" msgstr "Eine Adresse pro Zeile" #: uffd/templates/mfa/auth.html:6 uffd/templates/selfservice/self.html:158 -#: uffd/templates/user/show.html:172 +#: uffd/templates/user/show.html:176 msgid "Two-Factor Authentication" msgstr "Zwei-Faktor-Authentifizierung" @@ -595,7 +599,7 @@ msgid "Reset two-factor configuration" msgstr "Zwei-Faktor-Authentifizierung zurücksetzen" #: uffd/templates/mfa/setup.html:46 uffd/templates/mfa/setup_recovery.html:5 -#: uffd/templates/user/show.html:175 +#: uffd/templates/user/show.html:179 msgid "Recovery Codes" msgstr "Wiederherstellungscodes" @@ -633,7 +637,7 @@ msgstr "Generiere neue Wiederherstellungscodes" msgid "You have no remaining recovery codes." msgstr "Du hast keine Wiederherstellungscodes übrig." -#: uffd/templates/mfa/setup.html:85 uffd/templates/user/show.html:175 +#: uffd/templates/mfa/setup.html:85 uffd/templates/user/show.html:179 msgid "Authenticator Apps (TOTP)" msgstr "Authentifikator-Apps (TOTP)" @@ -663,7 +667,7 @@ msgstr "Registriert am" msgid "No authenticator apps registered yet" msgstr "Bisher keine Authentifikator-Apps registriert" -#: uffd/templates/mfa/setup.html:134 uffd/templates/user/show.html:175 +#: uffd/templates/mfa/setup.html:134 uffd/templates/user/show.html:179 msgid "U2F and FIDO2 Devices" msgstr "U2F und FIDO2 Geräte" @@ -968,7 +972,7 @@ msgstr "Passwort vergessen" #: uffd/templates/selfservice/forgot_password.html:9 #: uffd/templates/selfservice/self.html:21 uffd/templates/session/login.html:12 #: uffd/templates/signup/start.html:9 uffd/templates/user/list.html:18 -#: uffd/templates/user/show.html:62 +#: uffd/templates/user/show.html:66 msgid "Login Name" msgstr "Anmeldename" @@ -989,7 +993,7 @@ msgstr "" "Authentifizierung.\n" "\tDiese Berechtigungen werden erst aktiv, wenn du dies getan hast." -#: uffd/templates/selfservice/self.html:14 uffd/templates/user/show.html:32 +#: uffd/templates/selfservice/self.html:14 uffd/templates/user/show.html:36 msgid "Profile" msgstr "Profil" @@ -1006,7 +1010,7 @@ msgid "Changes may take several minutes to be visible in all services." msgstr "Änderungen sind erst nach einigen Minuten in allen Diensten sichtbar." #: uffd/templates/selfservice/self.html:25 uffd/templates/signup/start.html:22 -#: uffd/templates/user/list.html:19 uffd/templates/user/show.html:77 +#: uffd/templates/user/list.html:19 uffd/templates/user/show.html:81 msgid "Display Name" msgstr "Anzeigename" @@ -1014,7 +1018,7 @@ msgstr "Anzeigename" msgid "Update Profile" msgstr "Änderungen speichern" -#: uffd/templates/selfservice/self.html:37 uffd/templates/user/show.html:94 +#: uffd/templates/selfservice/self.html:37 uffd/templates/user/show.html:98 msgid "E-Mail Addresses" msgstr "E-Mail-Adressen" @@ -1039,7 +1043,7 @@ msgstr "Neue E-Mail-Adresse" msgid "Add address" msgstr "Adresse hinzufügen" -#: uffd/templates/selfservice/self.html:55 uffd/templates/user/show.html:110 +#: uffd/templates/selfservice/self.html:55 uffd/templates/user/show.html:114 msgid "primary" msgstr "primär" @@ -1047,7 +1051,7 @@ msgstr "primär" msgid "unverified" msgstr "nicht bestätigt" -#: uffd/templates/selfservice/self.html:62 uffd/views/selfservice.py:171 +#: uffd/templates/selfservice/self.html:62 uffd/views/selfservice.py:175 msgid "Cannot delete primary e-mail address" msgstr "Primäre E-Mail-Adresse kann nicht gelöscht werden" @@ -1086,8 +1090,8 @@ msgid "Address for Password Reset E-Mails" msgstr "Adresse für Passwort-Zurücksetzen-E-Mails" #: uffd/templates/selfservice/self.html:101 -#: uffd/templates/selfservice/self.html:115 uffd/templates/user/show.html:139 -#: uffd/templates/user/show.html:149 +#: uffd/templates/selfservice/self.html:115 uffd/templates/user/show.html:143 +#: uffd/templates/user/show.html:153 msgid "Use primary address" msgstr "Primäre Adresse verwenden" @@ -1106,7 +1110,7 @@ msgstr "E-Mail-Einstellungen speichern" #: uffd/templates/selfservice/self.html:135 #: uffd/templates/session/login.html:16 uffd/templates/signup/start.html:36 -#: uffd/templates/user/show.html:159 +#: uffd/templates/user/show.html:163 msgid "Password" msgstr "Passwort" @@ -1151,7 +1155,7 @@ msgid "Manage two-factor authentication" msgstr "Zwei-Faktor-Authentifizierung (2FA) verwalten" #: uffd/templates/selfservice/self.html:177 uffd/templates/user/list.html:20 -#: uffd/templates/user/show.html:35 uffd/templates/user/show.html:184 +#: uffd/templates/user/show.html:39 uffd/templates/user/show.html:188 #: uffd/views/role.py:21 msgid "Roles" msgstr "Rollen" @@ -1227,7 +1231,7 @@ msgstr "Zugriff auf Mail-Weiterleitungen" msgid "Resolve remailer addresses" msgstr "Auflösen von Remailer-Adressen" -#: uffd/templates/service/api.html:51 uffd/templates/service/show.html:37 +#: uffd/templates/service/api.html:51 uffd/templates/service/show.html:38 msgid "This option has no effect: Remailer config options are unset" msgstr "Diese Option hat keine Auswirkung: Remailer ist nicht konfiguriert" @@ -1235,7 +1239,7 @@ msgstr "Diese Option hat keine Auswirkung: Remailer ist nicht konfiguriert" msgid "Access uffd metrics" msgstr "Zugriff auf uffd-Metriken" -#: uffd/templates/service/oauth2.html:20 uffd/templates/service/show.html:79 +#: uffd/templates/service/oauth2.html:20 uffd/templates/service/show.html:81 msgid "Client ID" msgstr "Client-ID" @@ -1308,21 +1312,21 @@ msgstr "Mitglieder der Gruppe \"%(group_name)s\" haben Zugriff" msgid "Hide e-mail addresses with remailer" msgstr "E-Mail-Adressen mit Remailer verstecken" -#: uffd/templates/service/show.html:41 +#: uffd/templates/service/show.html:43 msgid "Remailer disabled" msgstr "Remailer deaktiviert" -#: uffd/templates/service/show.html:44 +#: uffd/templates/service/show.html:46 msgid "Remailer enabled" msgstr "Remailer aktiviert" -#: uffd/templates/service/show.html:47 +#: uffd/templates/service/show.html:49 msgid "Remailer enabled (deprecated, case-sensitive format)" msgstr "" "Remailer aktiviert (veraltetes, Groß-/Kleinschreibung-unterscheidendes " "Format)" -#: uffd/templates/service/show.html:51 +#: uffd/templates/service/show.html:53 msgid "" "Some services notify users about changes to their e-mail address. " "Modifying this setting immediatly affects the e-mail addresses of all " @@ -1333,13 +1337,13 @@ msgstr "" "-Mail-Adressen aller Nutzer aus und kann zu massenhaftem Versand von " "Benachrichtigungs-E-Mails führen." -#: uffd/templates/service/show.html:58 +#: uffd/templates/service/show.html:60 msgid "Allow users to select a different e-mail address for this service" msgstr "" "Ermögliche Nutzern für diesen Dienst eine andere E-Mail-Adresse " "auszuwählen" -#: uffd/templates/service/show.html:60 +#: uffd/templates/service/show.html:62 msgid "If disabled, the service always uses the primary e-mail address." msgstr "Wenn deaktiviert, wird immer die primäre E-Mail-Adresse verwendet." @@ -1445,7 +1449,7 @@ msgstr "Überprüfen" msgid "At least one and at most 128 characters, no other special requirements." msgstr "Mindestens 1 und maximal 128 Zeichen, keine weiteren Einschränkungen." -#: uffd/templates/signup/start.html:29 uffd/templates/user/show.html:86 +#: uffd/templates/signup/start.html:29 uffd/templates/user/show.html:90 msgid "E-Mail Address" msgstr "E-Mail-Adresse" @@ -1505,7 +1509,7 @@ msgstr "CSV-Import" msgid "UID" msgstr "UID" -#: uffd/templates/user/list.html:34 uffd/templates/user/show.html:44 +#: uffd/templates/user/list.html:34 uffd/templates/user/show.html:48 msgid "service" msgstr "service" @@ -1523,7 +1527,7 @@ msgstr "" "Anzeigename oder das Password können (derzeit) nicht gesetzt werden. " "Beispiel:" -#: uffd/templates/user/list.html:75 uffd/templates/user/show.html:72 +#: uffd/templates/user/list.html:75 uffd/templates/user/show.html:76 msgid "Ignore login name blocklist" msgstr "Liste der nicht erlaubten Anmeldenamen ignorieren" @@ -1535,19 +1539,19 @@ msgstr "Importieren" msgid "New address" msgstr "Neue Adresse" -#: uffd/templates/user/show.html:42 +#: uffd/templates/user/show.html:46 msgid "User ID" msgstr "Account ID" -#: uffd/templates/user/show.html:50 +#: uffd/templates/user/show.html:54 msgid "will be choosen" msgstr "wird automatisch bestimmt" -#: uffd/templates/user/show.html:57 +#: uffd/templates/user/show.html:61 msgid "Service User" msgstr "Service-Account" -#: uffd/templates/user/show.html:65 +#: uffd/templates/user/show.html:69 msgid "" "Only letters, numbers, dashes (\"-\") and underscores (\"_\") are " "allowed. At most 32, at least 2 characters. There is a word blocklist. " @@ -1557,7 +1561,7 @@ msgstr "" "erlaubt. Maximal 32, mindestens 2 Zeichen. Es gibt eine Liste nicht " "erlaubter Namen. Muss einmalig sein." -#: uffd/templates/user/show.html:80 +#: uffd/templates/user/show.html:84 msgid "" "If you leave this empty it will be set to the login name. At most 128, at" " least 2 characters. No character restrictions." @@ -1565,7 +1569,7 @@ msgstr "" "Wenn das Feld leer bleibt, wird der Anmeldename verwendet. Maximal 128, " "mindestens 2 Zeichen. Keine Zeichenbeschränkung." -#: uffd/templates/user/show.html:89 +#: uffd/templates/user/show.html:93 msgid "" "Make sure the address is correct! Services might use e-mail addresses as " "account identifiers and rely on them being unique and verified." @@ -1574,15 +1578,15 @@ msgstr "" " E-Mail-Adresse um Accounts zu identifizieren und verlassen sich darauf, " "dass diese verifiziert und einzigartig sind." -#: uffd/templates/user/show.html:98 +#: uffd/templates/user/show.html:102 msgid "Address" msgstr "Adresse" -#: uffd/templates/user/show.html:99 +#: uffd/templates/user/show.html:103 msgid "Verified" msgstr "Verifiziert" -#: uffd/templates/user/show.html:125 +#: uffd/templates/user/show.html:129 msgid "" "Make sure that addresses you add are correct! Services might use e-mail " "addresses as account identifiers and rely on them being unique and " @@ -1592,36 +1596,36 @@ msgstr "" "Dienste verwenden die E-Mail-Adresse um Accounts zu identifizieren und " "verlassen sich darauf, dass diese verifiziert und einzigartig sind." -#: uffd/templates/user/show.html:129 +#: uffd/templates/user/show.html:133 msgid "Primary E-Mail Address" msgstr "Primäre E-Mail-Adresse" -#: uffd/templates/user/show.html:137 +#: uffd/templates/user/show.html:141 msgid "Recovery E-Mail Address" msgstr "Wiederherstellungs-E-Mail-Adresse" -#: uffd/templates/user/show.html:147 +#: uffd/templates/user/show.html:151 #, python-format msgid "Address for %(name)s" msgstr "Adresse für %(name)s" -#: uffd/templates/user/show.html:163 +#: uffd/templates/user/show.html:167 msgid "E-Mail to set it will be sent" msgstr "Mail zum Setzen wird versendet" -#: uffd/templates/user/show.html:174 +#: uffd/templates/user/show.html:178 msgid "Status:" msgstr "Status:" -#: uffd/templates/user/show.html:174 +#: uffd/templates/user/show.html:178 msgid "Enabled" msgstr "Aktiv" -#: uffd/templates/user/show.html:177 +#: uffd/templates/user/show.html:181 msgid "Reset 2FA" msgstr "2FA zurücksetzen" -#: uffd/templates/user/show.html:217 +#: uffd/templates/user/show.html:221 msgid "Resulting groups (only updated after save)" msgstr "Resultierende Gruppen (wird nur aktualisiert beim Speichern)" @@ -1864,13 +1868,13 @@ msgstr "Passwort geändert" msgid "E-Mail address already exists" msgstr "E-Mail-Adresse existiert bereits" -#: uffd/views/selfservice.py:124 uffd/views/selfservice.py:158 -#: uffd/views/selfservice.py:223 +#: uffd/views/selfservice.py:124 uffd/views/selfservice.py:162 +#: uffd/views/selfservice.py:227 #, python-format msgid "E-Mail to \"%(mail_address)s\" could not be sent!" msgstr "E-Mail an \"%(mail_address)s\" konnte nicht gesendet werden!" -#: uffd/views/selfservice.py:126 uffd/views/selfservice.py:160 +#: uffd/views/selfservice.py:126 uffd/views/selfservice.py:164 msgid "We sent you an email, please verify your mail address." msgstr "Wir haben dir eine E-Mail gesendet, bitte prüfe deine E-Mail-Adresse." @@ -1882,19 +1886,23 @@ msgstr "" "Dieser Link wurde für einen anderen Account erstellt. Melde dich mit dem " "richtigen Account an um Fortzufahren." -#: uffd/views/selfservice.py:148 +#: uffd/views/selfservice.py:150 +msgid "E-Mail address is already used by another account" +msgstr "E-Mail-Adresse wird bereits von einem anderen Account verwendet" + +#: uffd/views/selfservice.py:152 msgid "E-Mail address verified" msgstr "E-Mail-Adresse verifiziert" -#: uffd/views/selfservice.py:173 +#: uffd/views/selfservice.py:177 msgid "E-Mail address deleted" msgstr "E-Mail-Adresse gelöscht" -#: uffd/views/selfservice.py:194 +#: uffd/views/selfservice.py:198 msgid "E-Mail preferences updated" msgstr "E-Mail-Einstellungen geändert" -#: uffd/views/selfservice.py:205 +#: uffd/views/selfservice.py:209 #, python-format msgid "You left role %(role_name)s" msgstr "Rolle %(role_name)s verlassen" @@ -1949,7 +1957,7 @@ msgstr "Ungültiger Account-Registrierungs-Link" msgid "Too many failed attempts! Please wait %(delay)s." msgstr "Zu viele fehlgeschlagene Versuche! Bitte warte mindestens %(delay)s." -#: uffd/views/signup.py:112 +#: uffd/views/signup.py:113 msgid "Your account was successfully created" msgstr "Account erfolgreich erstellt" @@ -1957,33 +1965,39 @@ msgstr "Account erfolgreich erstellt" msgid "Users" msgstr "Accounts" -#: uffd/views/user.py:51 +#: uffd/views/user.py:48 msgid "Login name does not meet requirements" msgstr "Anmeldename entspricht nicht den Anforderungen" -#: uffd/views/user.py:98 +#: uffd/views/user.py:55 uffd/views/user.py:129 msgid "Display name does not meet requirements" msgstr "Anzeigename entspricht nicht den Anforderungen" -#: uffd/views/user.py:104 -msgid "Password is invalid" -msgstr "Passwort ist ungültig" - -#: uffd/views/user.py:120 +#: uffd/views/user.py:74 msgid "Service user created" msgstr "Service-Account erstellt" -#: uffd/views/user.py:123 +#: uffd/views/user.py:77 msgid "User created. We sent the user a password reset link by e-mail" msgstr "" "Account erstellt. E-Mail mit einem Link zum Setzen des Passworts wurde " "versendet." -#: uffd/views/user.py:125 +#: uffd/views/user.py:106 +msgid "E-Mail address already exists or is used by another account" +msgstr "" +"E-Mail-Adresse existiert bereits oder wird von einem anderen Account " +"verwendet" + +#: uffd/views/user.py:134 +msgid "Password is invalid" +msgstr "Passwort ist ungültig" + +#: uffd/views/user.py:146 msgid "User updated" msgstr "Account aktualisiert" -#: uffd/views/user.py:135 +#: uffd/views/user.py:156 msgid "Deleted user" msgstr "Account gelöscht" diff --git a/uffd/views/selfservice.py b/uffd/views/selfservice.py index 5d9b337b..b4cbd4c8 100644 --- a/uffd/views/selfservice.py +++ b/uffd/views/selfservice.py @@ -144,7 +144,11 @@ def verify_email(secret, email_id=None, legacy_id=None): return redirect(url_for('selfservice.index')) if legacy_id is not None: request.user.primary_email = email - db.session.commit() + try: + db.session.commit() + except IntegrityError: + flash(_('E-Mail address is already used by another account')) + return redirect(url_for('selfservice.index')) flash(_('E-Mail address verified')) return redirect(url_for('selfservice.index')) diff --git a/uffd/views/signup.py b/uffd/views/signup.py index edbd3dcb..a5a779da 100644 --- a/uffd/views/signup.py +++ b/uffd/views/signup.py @@ -105,6 +105,7 @@ def signup_confirm_submit(signup_id, token): return render_template('signup/confirm.html', signup=signup) user, msg = signup.finish(request.form['password']) if user is None: + db.session.rollback() flash(msg, 'error') return render_template('signup/confirm.html', signup=signup) db.session.commit() diff --git a/uffd/views/user.py b/uffd/views/user.py index b3125667..b3ba6788 100644 --- a/uffd/views/user.py +++ b/uffd/views/user.py @@ -37,75 +37,103 @@ def show(id=None): user = User() if id is None else User.query.get_or_404(id) return render_template('user/show.html', user=user, roles=Role.query.all()) -@bp.route("/<int:id>/update", methods=['POST']) @bp.route("/new", methods=['POST']) @csrf_protect(blueprint=bp) -def update(id=None): +def create(): + user = User() + if request.form.get('serviceaccount'): + user.is_service_user = True + ignore_blocklist = request.form.get('ignore-loginname-blocklist', False) + if not user.set_loginname(request.form['loginname'], ignore_blocklist=ignore_blocklist): + flash(_('Login name does not meet requirements')) + return redirect(url_for('user.show')) + if not user.set_primary_email_address(request.form['email']): + flash(_('E-Mail address is invalid')) + return redirect(url_for('user.show')) + new_displayname = request.form['displayname'] if request.form['displayname'] else request.form['loginname'] + if user.displayname != new_displayname and not user.set_displayname(new_displayname): + flash(_('Display name does not meet requirements')) + return redirect(url_for('user.show')) + + db.session.add(user) + try: + db.session.flush() + except IntegrityError: + flash(_('Login name or e-mail address is already in use')) + return redirect(url_for('user.show')) + + for role in Role.query.all(): + if not user.is_service_user and role.is_default: + continue + if request.values.get('role-{}'.format(role.id), False): + user.roles.append(role) + user.update_groups() + + db.session.commit() + if user.is_service_user: + flash(_('Service user created')) + else: + send_passwordreset(user, new=True) + flash(_('User created. We sent the user a password reset link by e-mail')) + return redirect(url_for('user.show', id=user.id)) + +@bp.route("/<int:id>/update", methods=['POST']) +@csrf_protect(blueprint=bp) +def update(id): # pylint: disable=too-many-branches,too-many-statements - if id is None: - user = User() - ignore_blocklist = request.form.get('ignore-loginname-blocklist', False) - if request.form.get('serviceaccount'): - user.is_service_user = True - if not user.set_loginname(request.form['loginname'], ignore_blocklist=ignore_blocklist): - flash(_('Login name does not meet requirements')) - return redirect(url_for('user.show')) - if not user.set_primary_email_address(request.form['email']): + user = User.query.get_or_404(id) + + for email in user.all_emails: + if f'email-{email.id}-present' in request.form: + email.verified = email.verified or (request.form.get(f'email-{email.id}-verified') == '1') + for key, value in request.form.items(): + parts = key.split('-') + if not parts[0] == 'newemail' or not parts[2] == 'address' or not value: + continue + tmp_id = parts[1] + email = UserEmail( + user=user, + verified=(request.form.get(f'newemail-{tmp_id}-verified') == '1'), + ) + if not email.set_address(value): flash(_('E-Mail address is invalid')) - return redirect(url_for('user.show')) - else: - user = User.query.get_or_404(id) + return redirect(url_for('user.show', id=id)) + db.session.add(email) - for email in user.all_emails: - if f'email-{email.id}-present' in request.form: - email.verified = email.verified or (request.form.get(f'email-{email.id}-verified') == '1') + try: + db.session.flush() + except IntegrityError: + flash(_('E-Mail address already exists or is used by another account')) + return redirect(url_for('user.show', id=id)) - for key, value in request.form.items(): - parts = key.split('-') - if not parts[0] == 'newemail' or not parts[2] == 'address' or not value: - continue - tmp_id = parts[1] - email = UserEmail( - user=user, - verified=(request.form.get(f'newemail-{tmp_id}-verified') == '1'), - ) - if not email.set_address(value): - flash(_('E-Mail address is invalid')) - return redirect(url_for('user.show', id=id)) - db.session.add(email) - - verified_emails = UserEmail.query.filter_by(user=user, verified=True) - user.primary_email = verified_emails.filter_by(id=request.form['primary_email']).one() - if request.form['recovery_email'] == 'primary': - user.recovery_email = None + verified_emails = UserEmail.query.filter_by(user=user, verified=True) + user.primary_email = verified_emails.filter_by(id=request.form['primary_email']).one() + if request.form['recovery_email'] == 'primary': + user.recovery_email = None + else: + user.recovery_email = verified_emails.filter_by(id=request.form['recovery_email']).one() + for service_user in user.service_users: + if not service_user.service.enable_email_preferences: + continue + value = request.form.get(f'service_{service_user.service.id}_email', 'primary') + if value == 'primary': + service_user.service_email = None else: - user.recovery_email = verified_emails.filter_by(id=request.form['recovery_email']).one() - for service_user in user.service_users: - if not service_user.service.enable_email_preferences: - continue - value = request.form.get(f'service_{service_user.service.id}_email', 'primary') - if value == 'primary': - service_user.service_email = None - else: - service_user.service_email = verified_emails.filter_by(id=value).one() - - for email in user.all_emails: - if request.form.get(f'email-{email.id}-delete') == '1': - db.session.delete(email) + service_user.service_email = verified_emails.filter_by(id=value).one() + for email in user.all_emails: + if request.form.get(f'email-{email.id}-delete') == '1': + db.session.delete(email) new_displayname = request.form['displayname'] if request.form['displayname'] else request.form['loginname'] if user.displayname != new_displayname and not user.set_displayname(new_displayname): flash(_('Display name does not meet requirements')) return redirect(url_for('user.show', id=id)) - new_password = request.form.get('password') - if id is not None and new_password: + if new_password: if not user.set_password(new_password): flash(_('Password is invalid')) return redirect(url_for('user.show', id=id)) - db.session.add(user) - user.roles.clear() for role in Role.query.all(): if not user.is_service_user and role.is_default: @@ -115,14 +143,7 @@ def update(id=None): user.update_groups() db.session.commit() - if id is None: - if user.is_service_user: - flash(_('Service user created')) - else: - send_passwordreset(user, new=True) - flash(_('User created. We sent the user a password reset link by e-mail')) - else: - flash(_('User updated')) + flash(_('User updated')) return redirect(url_for('user.show', id=user.id)) @bp.route("/<int:id>/del") -- GitLab