From 879a04c576f9d6db43f27a1ecb712080976c0e47 Mon Sep 17 00:00:00 2001 From: Julian Rother <julian@cccv.de> Date: Thu, 20 Oct 2022 18:33:21 +0200 Subject: [PATCH] Remailer address format v2 Deprecates old case-sensitive format. Some software out there stores email addresses converted to lower case, breaking v1 remailer addresses. The new format is case-insensitive and generally more robust. Uffd continues to use and support the v1 format for services setup before this change. Support for the old format is planned to be remove in uffd v3. It is possbile to gradually migrate services to the new format with a service setting in the admin interface. Also fixes compatability issue with very recent SQLAlchemy versions introduced by b391e17 (whens parameter of case function). --- tests/test_api.py | 16 ++--- tests/test_oauth2.py | 6 +- tests/test_remailer.py | 64 +++++++++++------ tests/test_services.py | 47 +++++-------- tests/test_utils.py | 12 ++++ .../versions/2b68f688bec1_remailer_v2.py | 66 ++++++++++++++++++ uffd/models/__init__.py | 5 +- uffd/models/mfa.py | 6 +- uffd/models/service.py | 39 ++++++----- uffd/remailer.py | 53 +++++++++++--- uffd/templates/service/show.html | 28 ++++++-- uffd/translations/de/LC_MESSAGES/messages.mo | Bin 38702 -> 39636 bytes uffd/translations/de/LC_MESSAGES/messages.po | 57 +++++++++++---- uffd/utils.py | 17 +++++ uffd/views/service.py | 6 +- 15 files changed, 309 insertions(+), 113 deletions(-) create mode 100644 tests/test_utils.py create mode 100644 uffd/migrations/versions/2b68f688bec1_remailer_v2.py diff --git a/tests/test_api.py b/tests/test_api.py index 9200fc89..678aa467 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -5,7 +5,7 @@ from flask import url_for from uffd.password_hash import PlaintextPasswordHash from uffd.remailer import remailer from uffd.database import db -from uffd.models import APIClient, Service, User +from uffd.models import APIClient, Service, User, RemailerMode from uffd.views.api import apikey_required from utils import UffdTestCase, db_flush @@ -141,7 +141,7 @@ class TestAPIGetusers(UffdTestCase): def test_with_remailer(self): service = Service.query.filter_by(name='test').one() - service.use_remailer = True + service.remailer_mode = RemailerMode.ENABLED_V1 db.session.commit() user = self.get_user() self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' @@ -149,7 +149,7 @@ class TestAPIGetusers(UffdTestCase): self.assertEqual(r.status_code, 200) service = Service.query.filter_by(name='test').one() self.assertEqual(self.fix_result(r.json), [ - {'displayname': 'Test User', 'email': remailer.build_address(service.id, user.id), 'id': 10000, 'loginname': 'testuser', 'groups': ['uffd_access', 'users']}, + {'displayname': 'Test User', 'email': remailer.build_v1_address(service.id, user.id), 'id': 10000, 'loginname': 'testuser', 'groups': ['uffd_access', 'users']}, ]) def test_loginname(self): @@ -173,15 +173,15 @@ class TestAPIGetusers(UffdTestCase): def test_email_with_remailer(self): service = Service.query.filter_by(name='test').one() - service.use_remailer = True + service.remailer_mode = RemailerMode.ENABLED_V1 db.session.commit() user = self.get_admin() self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' - r = self.client.get(path=url_for('api.getusers', email=remailer.build_address(service.id, user.id)), headers=[basic_auth('test', 'test')], follow_redirects=True) + r = self.client.get(path=url_for('api.getusers', email=remailer.build_v1_address(service.id, user.id)), headers=[basic_auth('test', 'test')], follow_redirects=True) self.assertEqual(r.status_code, 200) service = Service.query.filter_by(name='test').one() self.assertEqual(self.fix_result(r.json), [ - {'displayname': 'Test Admin', 'email': remailer.build_address(service.id, user.id), 'id': 10001, 'loginname': 'testadmin', 'groups': ['uffd_access', 'uffd_admin', 'users']} + {'displayname': 'Test Admin', 'email': remailer.build_v1_address(service.id, user.id), 'id': 10001, 'loginname': 'testadmin', 'groups': ['uffd_access', 'uffd_admin', 'users']} ]) def test_email_empty(self): @@ -261,12 +261,12 @@ class TestAPIRemailerResolve(UffdTestCase): def setUpDB(self): db.session.add(APIClient(service=Service(name='test'), auth_username='test', auth_password='test', perm_remailer=True)) db.session.add(Service(name='service1')) - db.session.add(Service(name='service2', use_remailer=True)) + db.session.add(Service(name='service2', remailer_mode=RemailerMode.ENABLED_V1)) def test(self): self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' service = Service.query.filter_by(name='service2').one() - r = self.client.get(path=url_for('api.resolve_remailer', orig_address=remailer.build_address(service.id, self.get_user().id)), headers=[basic_auth('test', 'test')], follow_redirects=True) + r = self.client.get(path=url_for('api.resolve_remailer', orig_address=remailer.build_v1_address(service.id, self.get_user().id)), headers=[basic_auth('test', 'test')], follow_redirects=True) self.assertEqual(r.status_code, 200) self.assertEqual(r.json, {'address': self.get_user().primary_email.address}) r = self.client.get(path=url_for('api.resolve_remailer', orig_address='foo@bar'), headers=[basic_auth('test', 'test')], follow_redirects=True) diff --git a/tests/test_oauth2.py b/tests/test_oauth2.py index 1dea4074..e47ba995 100644 --- a/tests/test_oauth2.py +++ b/tests/test_oauth2.py @@ -6,7 +6,7 @@ from flask import url_for, session from uffd import create_app, db from uffd.password_hash import PlaintextPasswordHash from uffd.remailer import remailer -from uffd.models import User, DeviceLoginConfirmation, Service, OAuth2Client, OAuth2DeviceLoginInitiation +from uffd.models import User, DeviceLoginConfirmation, Service, OAuth2Client, OAuth2DeviceLoginInitiation, RemailerMode from utils import dump, UffdTestCase @@ -50,12 +50,12 @@ class TestViews(UffdTestCase): def test_authorization_with_remailer(self): self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' service = Service.query.filter_by(name='test').one() - service.use_remailer = True + service.remailer_mode = RemailerMode.ENABLED_V1 db.session.commit() self.login_as('user') r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback', scope='profile'), follow_redirects=False) service = Service.query.filter_by(name='test').one() - self.assert_authorization(r, mail=remailer.build_address(service.id, self.get_user().id)) + self.assert_authorization(r, mail=remailer.build_v1_address(service.id, self.get_user().id)) def test_authorization_client_secret_rehash(self): OAuth2Client.query.delete() diff --git a/tests/test_remailer.py b/tests/test_remailer.py index 8ef261fc..239f3743 100644 --- a/tests/test_remailer.py +++ b/tests/test_remailer.py @@ -5,6 +5,10 @@ from utils import UffdTestCase USER_ID = 1234 SERVICE1_ID = 4223 SERVICE2_ID = 3242 +ADDR_V1_S1 = 'v1-WzQyMjMsMTIzNF0.MeO6bHGTgIyPvvq2r3xriokLMCU@remailer.example.com' +ADDR_V1_S2 = 'v1-WzMyNDIsMTIzNF0.p2a_RkJc0oHBc9u4_S8G9METflA@remailer.example.com' +ADDR_V2_S1 = 'v2-lm2demrtfqytemzulu-ghr3u3drsoaizd567k3k67dlrkeqwmbf@remailer.example.com' +ADDR_V2_S2 = 'v2-lmztenbsfqytemzulu-u5tl6rscltjidqlt3o4p2lyg6targ7sq@remailer.example.com' class TestRemailer(UffdTestCase): def test_is_remailer_domain(self): @@ -20,44 +24,62 @@ class TestRemailer(UffdTestCase): self.assertTrue(remailer.is_remailer_domain('other.remailer.example.com')) self.assertFalse(remailer.is_remailer_domain('example.com')) - def test_build_address(self): + def test_build_v1_address(self): self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' - self.assertTrue(remailer.build_address(SERVICE1_ID, USER_ID).endswith('@remailer.example.com')) - self.assertTrue(remailer.build_address(SERVICE2_ID, USER_ID).endswith('@remailer.example.com')) - self.assertLessEqual(len(remailer.build_local_part(SERVICE1_ID, USER_ID)), 64) - self.assertLessEqual(len(remailer.build_address(SERVICE1_ID, USER_ID)), 256) - self.assertEqual(remailer.build_address(SERVICE1_ID, USER_ID), remailer.build_address(SERVICE1_ID, USER_ID)) - self.assertNotEqual(remailer.build_address(SERVICE1_ID, USER_ID), remailer.build_address(SERVICE2_ID, USER_ID)) - addr = remailer.build_address(SERVICE1_ID, USER_ID) + self.assertEqual(remailer.build_v1_address(SERVICE1_ID, USER_ID), ADDR_V1_S1) + self.assertEqual(remailer.build_v1_address(SERVICE2_ID, USER_ID), ADDR_V1_S2) + long_addr = remailer.build_v1_address(1000, 1000000) + self.assertLessEqual(len(long_addr.split('@')[0]), 64) + self.assertLessEqual(len(long_addr), 256) self.app.config['REMAILER_OLD_DOMAINS'] = ['old.remailer.example.com'] - self.assertEqual(remailer.build_address(SERVICE1_ID, USER_ID), addr) - self.assertTrue(remailer.build_address(SERVICE1_ID, USER_ID).endswith('@remailer.example.com')) + self.assertEqual(remailer.build_v1_address(SERVICE1_ID, USER_ID), ADDR_V1_S1) self.app.config['REMAILER_SECRET_KEY'] = self.app.config['SECRET_KEY'] - self.assertEqual(remailer.build_address(SERVICE1_ID, USER_ID), addr) + self.assertEqual(remailer.build_v1_address(SERVICE1_ID, USER_ID), ADDR_V1_S1) self.app.config['REMAILER_SECRET_KEY'] = 'REMAILER-DEBUGKEY' - self.assertNotEqual(remailer.build_address(SERVICE1_ID, USER_ID), addr) + self.assertNotEqual(remailer.build_v1_address(SERVICE1_ID, USER_ID), ADDR_V1_S1) - def test_parse_address(self): + def test_build_v2_address(self): self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' - addr = remailer.build_address(SERVICE2_ID, USER_ID) + self.assertEqual(remailer.build_v2_address(SERVICE1_ID, USER_ID), ADDR_V2_S1) + self.assertEqual(remailer.build_v2_address(SERVICE2_ID, USER_ID), ADDR_V2_S2) + long_addr = remailer.build_v2_address(1000, 1000000) + self.assertLessEqual(len(long_addr.split('@')[0]), 64) + self.assertLessEqual(len(long_addr), 256) + self.app.config['REMAILER_OLD_DOMAINS'] = ['old.remailer.example.com'] + self.assertEqual(remailer.build_v2_address(SERVICE1_ID, USER_ID), ADDR_V2_S1) + self.app.config['REMAILER_SECRET_KEY'] = self.app.config['SECRET_KEY'] + self.assertEqual(remailer.build_v2_address(SERVICE1_ID, USER_ID), ADDR_V2_S1) + self.app.config['REMAILER_SECRET_KEY'] = 'REMAILER-DEBUGKEY' + self.assertNotEqual(remailer.build_v2_address(SERVICE1_ID, USER_ID), ADDR_V2_S1) + + def test_parse_address(self): # REMAILER_DOMAIN behaviour self.app.config['REMAILER_DOMAIN'] = None - self.assertIsNone(remailer.parse_address(addr)) + self.assertIsNone(remailer.parse_address(ADDR_V1_S2)) + self.assertIsNone(remailer.parse_address(ADDR_V2_S2)) self.assertIsNone(remailer.parse_address('foo@example.com')) self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' - self.assertEqual(remailer.parse_address(addr), (SERVICE2_ID, USER_ID)) + self.assertEqual(remailer.parse_address(ADDR_V1_S2), (SERVICE2_ID, USER_ID)) + self.assertEqual(remailer.parse_address(ADDR_V2_S2), (SERVICE2_ID, USER_ID)) self.assertIsNone(remailer.parse_address('foo@example.com')) self.assertIsNone(remailer.parse_address('foo@remailer.example.com')) self.assertIsNone(remailer.parse_address('v1-foo@remailer.example.com')) + self.assertIsNone(remailer.parse_address('v2-foo@remailer.example.com')) + self.assertIsNone(remailer.parse_address('v2-foo-bar@remailer.example.com')) self.app.config['REMAILER_DOMAIN'] = 'new-remailer.example.com' - self.assertIsNone(remailer.parse_address(addr)) + self.assertIsNone(remailer.parse_address(ADDR_V1_S2)) + self.assertIsNone(remailer.parse_address(ADDR_V2_S2)) self.app.config['REMAILER_OLD_DOMAINS'] = ['remailer.example.com'] - self.assertEqual(remailer.parse_address(addr), (SERVICE2_ID, USER_ID)) + self.assertEqual(remailer.parse_address(ADDR_V1_S2), (SERVICE2_ID, USER_ID)) + self.assertEqual(remailer.parse_address(ADDR_V2_S2), (SERVICE2_ID, USER_ID)) # REMAILER_SECRET_KEY behaviour self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' self.app.config['REMAILER_OLD_DOMAINS'] = [] - self.assertEqual(remailer.parse_address(addr), (SERVICE2_ID, USER_ID)) + self.assertEqual(remailer.parse_address(ADDR_V1_S2), (SERVICE2_ID, USER_ID)) + self.assertEqual(remailer.parse_address(ADDR_V2_S2), (SERVICE2_ID, USER_ID)) self.app.config['REMAILER_SECRET_KEY'] = self.app.config['SECRET_KEY'] - self.assertEqual(remailer.parse_address(addr), (SERVICE2_ID, USER_ID)) + self.assertEqual(remailer.parse_address(ADDR_V1_S2), (SERVICE2_ID, USER_ID)) + self.assertEqual(remailer.parse_address(ADDR_V2_S2), (SERVICE2_ID, USER_ID)) self.app.config['REMAILER_SECRET_KEY'] = 'REMAILER-DEBUGKEY' - self.assertIsNone(remailer.parse_address(addr)) + self.assertIsNone(remailer.parse_address(ADDR_V1_S2)) + self.assertIsNone(remailer.parse_address(ADDR_V2_S2)) diff --git a/tests/test_services.py b/tests/test_services.py index 14ec4f70..5a1848bc 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -7,12 +7,12 @@ from utils import dump, UffdTestCase from uffd.remailer import remailer from uffd.tasks import cleanup_task from uffd.database import db -from uffd.models import Service, ServiceUser, User, UserEmail +from uffd.models import Service, ServiceUser, User, UserEmail, RemailerMode class TestServiceUser(UffdTestCase): def setUp(self): super().setUp() - db.session.add_all([Service(name='service1'), Service(name='service2', use_remailer=True)]) + db.session.add_all([Service(name='service1'), Service(name='service2', remailer_mode=RemailerMode.ENABLED_V1)]) db.session.commit() def test_auto_create(self): @@ -64,21 +64,12 @@ class TestServiceUser(UffdTestCase): service.enable_email_preferences = True self.assertEqual(service_user.real_email, service_user.service_email.address) - def test_remailer_email(self): - user = self.get_user() - service = Service.query.filter_by(name='service1').first() - service_user = ServiceUser.query.get((service.id, user.id)) - with self.assertRaises(Exception): - service_user.remailer_email - self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' - self.assertEqual(service_user.remailer_email, remailer.build_address(service.id, user.id)) - def test_get_by_remailer_email(self): user = self.get_user() service = Service.query.filter_by(name='service1').first() service_user = ServiceUser.query.get((service.id, user.id)) self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' - remailer_email = remailer.build_address(service.id, user.id) + remailer_email = remailer.build_v1_address(service.id, user.id) # 1. remailer not setup self.app.config['REMAILER_DOMAIN'] = '' self.assertIsNone(ServiceUser.get_by_remailer_email(user.primary_email.address)) @@ -95,21 +86,21 @@ class TestServiceUser(UffdTestCase): service = Service.query.filter_by(name='service1').first() service_user = ServiceUser.query.get((service.id, user.id)) self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' - remailer_email = remailer.build_address(service.id, user.id) + remailer_email = remailer.build_v1_address(service.id, user.id) # 1. remailer not setup self.app.config['REMAILER_DOMAIN'] = '' self.assertEqual(service_user.email, user.primary_email.address) - # 2. remailer setup + service.use_remailer disabled + # 2. remailer setup + remailer disabled self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' self.assertEqual(service_user.email, user.primary_email.address) - # 3. remailer setup + service.use_remailer enabled + REMAILER_LIMIT_TO_USERS unset - service.use_remailer = True + # 3. remailer setup + remailer enabled + REMAILER_LIMIT_TO_USERS unset + service.remailer_mode = RemailerMode.ENABLED_V1 db.session.commit() self.assertEqual(service_user.email, remailer_email) - # 4. remailer setup + service.use_remailer enabled + REMAILER_LIMIT_TO_USERS does not include user + # 4. remailer setup + remailer enabled + REMAILER_LIMIT_TO_USERS does not include user self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testadmin'] self.assertEqual(service_user.email, user.primary_email.address) - # 5. remailer setup + service.use_remailer enabled + REMAILER_LIMIT_TO_USERS includes user + # 5. remailer setup + remailer enabled + REMAILER_LIMIT_TO_USERS includes user self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testuser'] self.assertEqual(service_user.email, remailer_email) @@ -121,13 +112,13 @@ class TestServiceUser(UffdTestCase): user2 = User(loginname='user2', primary_email_address=user1.primary_email.address, displayname='User 2') db.session.add(user2) db.session.commit() - service1 = Service.query.filter_by(name='service1').first() # use_remailer=False - service2 = Service.query.filter_by(name='service2').first() # use_remailer=True + service1 = Service.query.filter_by(name='service1').first() # remailer disabled + service2 = Service.query.filter_by(name='service2').first() # remailer enabled self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' - remailer_email1_1 = remailer.build_address(service1.id, user1.id) - remailer_email2_1 = remailer.build_address(service2.id, user1.id) - remailer_email1_2 = remailer.build_address(service1.id, user2.id) - remailer_email2_2 = remailer.build_address(service2.id, user2.id) + remailer_email1_1 = remailer.build_v1_address(service1.id, user1.id) + remailer_email2_1 = remailer.build_v1_address(service2.id, user1.id) + remailer_email1_2 = remailer.build_v1_address(service1.id, user2.id) + remailer_email2_2 = remailer.build_v1_address(service2.id, user2.id) # 1. remailer disabled self.app.config['REMAILER_DOMAIN'] = '' @@ -181,11 +172,11 @@ class TestServiceUser(UffdTestCase): return {(su.service_id, su.user_id) for su in ServiceUser.filter_query_by_email(ServiceUser.query, value)} user1 = self.get_user() - service1 = Service.query.filter_by(name='service1').first() # use_remailer=False - service2 = Service.query.filter_by(name='service2').first() # use_remailer=True + service1 = Service.query.filter_by(name='service1').first() # remailer disabled + service2 = Service.query.filter_by(name='service2').first() # remailer enabled self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' - remailer_email1_1 = remailer.build_address(service1.id, user1.id) - remailer_email2_1 = remailer.build_address(service2.id, user1.id) + remailer_email1_1 = remailer.build_v1_address(service1.id, user1.id) + remailer_email2_1 = remailer.build_v1_address(service2.id, user1.id) self.app.config['REMAILER_DOMAIN'] = '' self.assertEqual(run_query(user1.primary_email.address), { diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..3d42f443 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,12 @@ +from uffd.utils import nopad_b32decode, nopad_b32encode, nopad_urlsafe_b64decode, nopad_urlsafe_b64encode + +from utils import UffdTestCase + +class TestUtils(UffdTestCase): + def test_nopad_b32(self): + for n in range(0, 32): + self.assertEqual(b'X'*n, nopad_b32decode(nopad_b32encode(b'X'*n))) + + def test_nopad_b64(self): + for n in range(0, 32): + self.assertEqual(b'X'*n, nopad_urlsafe_b64decode(nopad_urlsafe_b64encode(b'X'*n))) diff --git a/uffd/migrations/versions/2b68f688bec1_remailer_v2.py b/uffd/migrations/versions/2b68f688bec1_remailer_v2.py new file mode 100644 index 00000000..bdf95ccc --- /dev/null +++ b/uffd/migrations/versions/2b68f688bec1_remailer_v2.py @@ -0,0 +1,66 @@ +"""Remailer v2 + +Revision ID: 2b68f688bec1 +Revises: e13b733ec856 +Create Date: 2022-10-20 03:40:11.522343 + +""" +from alembic import op +import sqlalchemy as sa + +revision = '2b68f688bec1' +down_revision = 'e13b733ec856' +branch_labels = None +depends_on = None + +def upgrade(): + with op.batch_alter_table('service', schema=None) as batch_op: + batch_op.add_column(sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=False, server_default='DISABLED')) + service = sa.table('service', + sa.column('id', sa.Integer), + sa.column('use_remailer', sa.Boolean), + sa.column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode')), + ) + op.execute(service.update().values(remailer_mode='ENABLED_V1').where(service.c.use_remailer)) + meta = sa.MetaData(bind=op.get_bind()) + service = sa.Table('service', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('limit_access', sa.Boolean(), nullable=False), + sa.Column('access_group_id', sa.Integer(), nullable=True), + sa.Column('use_remailer', sa.Boolean(), nullable=False), + sa.Column('enable_email_preferences', sa.Boolean(), nullable=False), + sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=False, server_default='DISABLED'), + sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_service')), + sa.UniqueConstraint('name', name=op.f('uq_service_name')) + ) + with op.batch_alter_table('service', copy_from=service) as batch_op: + batch_op.alter_column('remailer_mode', server_default=None) + batch_op.drop_column('use_remailer') + +def downgrade(): + with op.batch_alter_table('service', schema=None) as batch_op: + batch_op.add_column(sa.Column('use_remailer', sa.BOOLEAN(), nullable=False, server_default=sa.false())) + service = sa.table('service', + sa.column('id', sa.Integer), + sa.column('use_remailer', sa.Boolean), + sa.column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode')), + ) + op.execute(service.update().values(use_remailer=sa.true()).where(service.c.remailer_mode != 'DISABLED')) + meta = sa.MetaData(bind=op.get_bind()) + service = sa.Table('service', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('limit_access', sa.Boolean(), nullable=False), + sa.Column('access_group_id', sa.Integer(), nullable=True), + sa.Column('use_remailer', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('enable_email_preferences', sa.Boolean(), nullable=False), + sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=False), + sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_service')), + sa.UniqueConstraint('name', name=op.f('uq_service_name')) + ) + with op.batch_alter_table('service', copy_from=service) as batch_op: + batch_op.alter_column('use_remailer', server_default=None) + batch_op.drop_column('remailer_mode') diff --git a/uffd/models/__init__.py b/uffd/models/__init__.py index 52d9709b..1644e472 100644 --- a/uffd/models/__init__.py +++ b/uffd/models/__init__.py @@ -5,7 +5,7 @@ from .mfa import MFAType, MFAMethod, RecoveryCodeMethod, TOTPMethod, WebauthnMet from .oauth2 import OAuth2Client, OAuth2RedirectURI, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation from .role import Role, RoleGroup, RoleGroupMap from .selfservice import PasswordToken -from .service import Service, ServiceUser, get_services +from .service import RemailerMode, Service, ServiceUser, get_services from .session import DeviceLoginType, DeviceLoginInitiation, DeviceLoginConfirmation from .signup import Signup from .user import User, UserEmail, Group @@ -19,8 +19,7 @@ __all__ = [ 'OAuth2Client', 'OAuth2RedirectURI', 'OAuth2LogoutURI', 'OAuth2Grant', 'OAuth2Token', 'OAuth2DeviceLoginInitiation', 'Role', 'RoleGroup', 'RoleGroupMap', 'PasswordToken', - 'Service', 'get_services', - 'Service', 'ServiceUser', 'get_services', + 'RemailerMode', 'Service', 'ServiceUser', 'get_services', 'DeviceLoginType', 'DeviceLoginInitiation', 'DeviceLoginConfirmation', 'Signup', 'User', 'UserEmail', 'Group', diff --git a/uffd/models/mfa.py b/uffd/models/mfa.py index 9d2b3458..e6b34eaa 100644 --- a/uffd/models/mfa.py +++ b/uffd/models/mfa.py @@ -15,6 +15,7 @@ from flask import request, current_app from sqlalchemy import Column, Integer, Enum, String, DateTime, Text, ForeignKey from sqlalchemy.orm import relationship, backref +from uffd.utils import nopad_b32decode, nopad_b32encode from uffd.database import db from .user import User @@ -88,13 +89,12 @@ class TOTPMethod(MFAMethod): def __init__(self, user, name=None, key=None): super().__init__(user, name) if key is None: - key = base64.b32encode(secrets.token_bytes(16)).rstrip(b'=').decode() + key = nopad_b32encode(secrets.token_bytes(16)).decode() self.key = key @property def raw_key(self): - tmp = self.key + '='*(8 - (len(self.key) % 8)) - return base64.b32decode(tmp.encode()) + return nopad_b32decode(self.key) @property def issuer(self): diff --git a/uffd/models/service.py b/uffd/models/service.py index f6927e28..c5e5cbcc 100644 --- a/uffd/models/service.py +++ b/uffd/models/service.py @@ -1,6 +1,8 @@ +import enum + from flask import current_app from flask_babel import get_locale -from sqlalchemy import Column, Integer, String, ForeignKey, Boolean +from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Enum from sqlalchemy.orm import relationship, validates from uffd.database import db @@ -8,6 +10,11 @@ from uffd.remailer import remailer from uffd.tasks import cleanup_task from .user import User, UserEmail +class RemailerMode(enum.Enum): + DISABLED = 0 + ENABLED_V1 = 1 + ENABLED_V2 = 2 + class Service(db.Model): __tablename__ = 'service' id = Column(Integer, primary_key=True, autoincrement=True) @@ -26,7 +33,7 @@ class Service(db.Model): oauth2_clients = relationship('OAuth2Client', back_populates='service', cascade='all, delete-orphan') api_clients = relationship('APIClient', back_populates='service', cascade='all, delete-orphan') - use_remailer = Column(Boolean(), default=False, nullable=False) + remailer_mode = Column(Enum(RemailerMode), default=RemailerMode.DISABLED, nullable=False) enable_email_preferences = Column(Boolean(), default=False, nullable=False) class ServiceUser(db.Model): @@ -72,12 +79,6 @@ class ServiceUser(db.Model): return self.service_email.address return self.user.primary_email.address - @property - def remailer_email(self): - if not remailer.configured: - raise Exception('ServiceUser.remailer_email accessed with unconfigured remailer') - return remailer.build_address(self.service_id, self.user_id) - @classmethod def get_by_remailer_email(cls, address): if not remailer.configured: @@ -91,11 +92,14 @@ class ServiceUser(db.Model): # E-Mail address as seen by the service @property def email(self): - use_remailer = remailer.configured and self.service.use_remailer - if current_app.config['REMAILER_LIMIT_TO_USERS'] is not None: - use_remailer = use_remailer and self.user.loginname in current_app.config['REMAILER_LIMIT_TO_USERS'] - if use_remailer: - return self.remailer_email + if current_app.config['REMAILER_LIMIT_TO_USERS'] is None: + use_remailer = remailer.configured + else: + use_remailer = remailer.configured and self.user.loginname in current_app.config['REMAILER_LIMIT_TO_USERS'] + if use_remailer and self.service.remailer_mode == RemailerMode.ENABLED_V1: + return remailer.build_v1_address(self.service_id, self.user_id) + if use_remailer and self.service.remailer_mode == RemailerMode.ENABLED_V2: + return remailer.build_v2_address(self.service_id, self.user_id) return self.real_email @classmethod @@ -117,17 +121,20 @@ class ServiceUser(db.Model): query = query.outerjoin(cls.service_email.of_type(AliasedServiceEmail)) query = query.join(cls.service.of_type(AliasedService)) - remailer_enabled_expr = AliasedService.use_remailer if remailer.configured else False + remailer_enabled_expr = db.and_( + AliasedService.remailer_mode != RemailerMode.DISABLED, + remailer.configured + ) if current_app.config['REMAILER_LIMIT_TO_USERS'] is not None: remailer_enabled_expr = db.and_( remailer_enabled_expr, AliasedUser.loginname.in_(current_app.config['REMAILER_LIMIT_TO_USERS']), ) real_email_matches_expr = db.case( - whens=( + whens=[ # pylint: disable=singleton-comparison (db.and_(AliasedService.enable_email_preferences, cls.service_email != None), AliasedServiceEmail.address == email), - ), + ], else_=(AliasedPrimaryEmail.address == email) ) return query.filter(db.and_(db.not_(remailer_enabled_expr), real_email_matches_expr)) diff --git a/uffd/remailer.py b/uffd/remailer.py index a4b37506..f3ffd079 100644 --- a/uffd/remailer.py +++ b/uffd/remailer.py @@ -1,13 +1,18 @@ from flask import current_app import itsdangerous +from uffd.utils import nopad_b32decode, nopad_b32encode, nopad_urlsafe_b64decode, nopad_urlsafe_b64encode + class Remailer: '''The remailer feature improves user privacy by hiding real mail addresses from services and instead providing them with autogenerated pseudonymous remailer addresses. If a service sends a mail to a remailer address, the mail service uses an uffd API endpoint to get the real mail address and rewrites the remailer address with it. In case of a leak of user data from a service, - the remailer addresses are useless for third-parties.''' + the remailer addresses are useless for third-parties. + + Version 2 of the remailer address format is tolerant to case conversions at + the cost of being slightly longer.''' # pylint: disable=no-self-use @@ -19,11 +24,16 @@ class Remailer: secret = current_app.config['REMAILER_SECRET_KEY'] or current_app.secret_key return itsdangerous.URLSafeSerializer(secret, salt='remailer_address_v1') - def build_local_part(self, service_id, user_id): - return 'v1-' + self.get_serializer().dumps([service_id, user_id]) + def build_v1_address(self, service_id, user_id): + payload = self.get_serializer().dumps([service_id, user_id]) + return 'v1-' + payload + '@' + current_app.config['REMAILER_DOMAIN'] - def build_address(self, service_id, user_id): - return self.build_local_part(service_id, user_id) + '@' + current_app.config['REMAILER_DOMAIN'] + def build_v2_address(self, service_id, user_id): + data, sign = self.get_serializer().dumps([service_id, user_id]).split('.', 1) + data = nopad_b32encode(nopad_urlsafe_b64decode(data)).decode().lower() + sign = nopad_b32encode(nopad_urlsafe_b64decode(sign)).decode().lower() + payload = data + '-' + sign + return 'v2-' + payload + '@' + current_app.config['REMAILER_DOMAIN'] def is_remailer_domain(self, domain): domains = {domain.lower().strip() for domain in current_app.config['REMAILER_OLD_DOMAINS']} @@ -31,17 +41,38 @@ class Remailer: domains.add(current_app.config['REMAILER_DOMAIN'].lower().strip()) return domain.lower().strip() in domains - def parse_address(self, address): - if '@' not in address: + def parse_v1_payload(self, payload): + try: + service_id, user_id = self.get_serializer().loads(payload) + except itsdangerous.BadData: return None - local_part, domain = address.rsplit('@', 1) - if not self.is_remailer_domain(domain) or not local_part.startswith('v1-'): + return (service_id, user_id) + + def parse_v2_payload(self, payload): + data, sign = (payload.split('-', 1) + [''])[:2] + try: + data = nopad_urlsafe_b64encode(nopad_b32decode(data.upper())).decode() + sign = nopad_urlsafe_b64encode(nopad_b32decode(sign.upper())).decode() + except ValueError: return None - data = local_part[len('v1-'):] + payload = data + '.' + sign try: - service_id, user_id = self.get_serializer().loads(data) + service_id, user_id = self.get_serializer().loads(payload) except itsdangerous.BadData: return None return (service_id, user_id) + def parse_address(self, address): + if '@' not in address: + return None + local_part, domain = address.rsplit('@', 1) + if not self.is_remailer_domain(domain): + return None + prefix, payload = (local_part.strip().split('-', 1) + [''])[:2] + if prefix == 'v1': + return self.parse_v1_payload(payload) + if prefix.lower() == 'v2': + return self.parse_v2_payload(payload) + return None + remailer = Remailer() diff --git a/uffd/templates/service/show.html b/uffd/templates/service/show.html index 55a12328..6c6fca94 100644 --- a/uffd/templates/service/show.html +++ b/uffd/templates/service/show.html @@ -30,19 +30,37 @@ {% endfor %} </select> </div> + <div class="form-group col"> - <div class="form-check"> - <input class="form-check-input" type="checkbox" id="service-use-remailer" name="use_remailer" value="1" aria-label="enabled" {{ 'checked' if service.use_remailer }}> - <label class="form-check-label" for="service-use-remailer">{{_('Hide mail addresses with remailer')}}</label> + <label for="remailer-mode"> + {{ _('Hide e-mail addresses with remailer') }} {% if not remailer.configured %} <i class="fas fa-exclamation-triangle text-warning" data-toggle="tooltip" data-placement="top" title="{{ _('This option has no effect: Remailer config options are unset') }}"></i> {% endif %} - </div> + </label> + <select class="form-control" id="remailer-mode" name="remailer-mode"> + <option value="{{ RemailerMode.DISABLED.name }}" {{ 'selected' if service.remailer_mode == RemailerMode.DISABLED }}> + {{ _('Remailer disabled') }} + </option> + <option value="{{ RemailerMode.ENABLED_V2.name }}" {{ 'selected' if service.remailer_mode == RemailerMode.ENABLED_V2 }}> + {{ _('Remailer enabled') }} + </option> + <option value="{{ RemailerMode.ENABLED_V1.name }}" {{ 'selected' if service.remailer_mode == RemailerMode.ENABLED_V1 }}> + {{ _('Remailer enabled (deprecated, case-sensitive format)') }} + </option> + </select> + <small class="form-text text-muted"> + {{ _('Some services notify users about changes to their e-mail address. Modifying this setting immediatly affects the e-mail addresses of all users and can cause masses of notification e-mails.') }} + </small> </div> + <div class="form-group col"> <div class="form-check"> <input class="form-check-input" type="checkbox" id="service-enable-email-preferences" name="enable_email_preferences" value="1" aria-label="enabled" {{ 'checked' if service.enable_email_preferences }}> - <label class="form-check-label" for="service-enable-email-preferences">{{ _('Enable user mail preferences') }}</label> + <label class="form-check-label" for="service-enable-email-preferences">{{ _('Allow users to select a different e-mail address for this service') }}</label> + <small class="form-text text-muted"> + {{ _('If disabled, the service always uses the primary e-mail address.') }} + </small> </div> </div> diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo index 8187048476a9226c2764f6b1fe76560a34e30c9d..52af6cfad16ce638fbfb8e1ea2d018fd317566d8 100644 GIT binary patch delta 7496 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 UoKn*>^YT&^QZhGZ)gKWD06!w4ApigX delta 6579 zcmcbzm1*5NruutAEK?a67#O-385m?37#MPRKs*HABf-GH&%nU2SAu~-n1O-etONrC z8v_HwLkR{39|i`77ZMB%AT6en3=CWh3=B?^5WcS@1A`U=149&4yj7BcftP`SVS*$B zgAfA)!#qi_dG!n%Aq<A2k_-$|3=9l6B^eml85kJ8NHQ>pFfcIukz`=tW?*0tlwx40 zW?*2Dm11B}WME*JDFsotTMDA?uoMG>5(5Ln4Jif&IR*xX-%<<=_6!URQql|zRv?E+ zGcXh~FfdG&hRCzafE-lMz@R3>z!1&Az+fW7z_1+TLKy}IcLoLq3t0w+S_TG&23d%~ z|6~~$#2FYE6y+ear5po;7y|=Cpd15(2?GN|h8)BLbLAKq+!z=bcE~X>NHQ=m{D-QO zl!t_*fjk3)1p@<vgFFKRS3Ls*L!~?<NL%C?7*rS-7`mbS4Nwi+<sm*fF3-Tg%D}+D zuE4+$%fP_EtH8j($-uyntpM@~14F3-M7~*pfq{jAfuU1@fx(c0fuT<UV(|e51_oIM z28Ig?3=E<S3=HoS7#QlM85kH?6d4!<85kH8ptO-90|N&G1A~hq#D~6$3=AN3DT?4Y zV`xxhVBlk5V3?u^iJHZVkhotD)wfF#;-SNe5Qm*ogjje(5t26ELG}GsWT*$nF}o5( zLIz4}DnWwIOo@R(lYxODKnY@Tl@bGkCCEog5DQK~)t^^_q=`F95QqPRinA+2EEZ8_ zU{GgZV9-*AL|u?FB&xEM>mdqSl_5Tw4W(BpGcX7+Ffi;?hNRY0%8;PDs|*Q|-^vgN z3aLO0)KP&L=%T{FpwGa-5U&DJ-><^JpuoVuuvP^UHRn_q7_1o>7_O*5qEw(>6_V`& zR3WK3SCxSwlYxPu6w1G^3h~i%Rfvy2s6rg}T@@0i9BPm#kWgb_U}j)oP*H<Km4+H5 zmAj}xEKF8|M8$kHh<R(F{Q7NB#t}70P+wAmSoB8?5+cm%kf0S(XJEJr$|dRy3>6Fv z3|txv3=<g`7@9R87K&;@^ciY0Ferm^k0!){F`5u_a-jTbO-OF))?{GV%)n63unsDb zqy-6qA}vVJR%t<kc8V4x?$>BR(#QcVNC=$Of;jk|79<2Yv?1zLwINYtsSObi(1tiH zR~r&iB~Wz@+8~G3GcZiihWK!?HUonO0|UcRZAdnJ2Q^4o2NG9WIuHkD=|BuD(qUlG z24!CzNZc;ffn>YYI*@F+K?f4|yL1>BR2di;ZtFli#Hb628cAIS1{YBNx6p;8&RSha z&@I)47<f__lC91|<!|Xi3}VuQBw7tU1_pTs1_nDlNQlMhK@wqw9>jr@^&mc;r3dlY zGChcc&g(%!@`D}&Lp`V<_^ZdjFol7EL0F%Gp@V^eVTC><^%@#LH252Uf{KBmzyJ~g z^#%+Kx(o~q?FJABZZ&|!^#LgVf&s+h+Xj%3cy0j66(0>CA*5)?z!1v7z@TQxP!Fy& zY78N%x7`qu-MS4SE}UlwF=(|RB+j=(<xdzwLh805#Df2ZkdWXsf<&FB5yTunBZ$64 zBL)UX1_p*ABL;>>1_p*LMvywg%or5^3=9n3#taM_85kHI7(=qhBohcd+XQ035)(+` z+G+wxbSF(9iR?3!&t(d6sEH}W;SQ#dG~;W^z>v<sz|d<738Am15Phs>5cM+kW{@D& zFoRfNU<L_l7c)p=2{vP3$Oo12W(*8%phRQ_$;V0N5W2t|;?O#Ch{5ep@p<MD^VXO% zFxWCMFdQ&vU@&4}VEAXwz#zfEz+h+rNh9@M77&*OT0nv_(E{RtYzs&#t+Ie5s+ks$ z`h1H8Br)B$fCMpzC4`o?gy`3^ggDFs%6GGbB<281NYNc;2{CViCD@#LhSipkL~+Fu z;-Z_D3=9#VsIY_tk);*H#jaM6ppApl*;bG^tgwQFK(7^~5}RfP@xThGy6sjB3^N!Q z7>+>I#acrgm}U)%+Ij|t5^IP-t=5nroMjDh$VO|3&vsZtQu%49{55Mx%jc~%q$ISm zfjD@x4Fkg@1_lNmTZsPowveb@2Bp{8LhRXQ3#pb*+cGe;g7W`gTLy*+prX_cl8;5~ zA#tZ?&%m%4)TFX!U<hDfV0dQFz+l6`z+mpcz+k|@z)<1<NsLP!AVGWH0n%{cbcC1_ z=m^Qa*^ZFZ-{Z)@;K0DZaLy5ud-$Cg80tX{1!E^j99cL)3Klyj28Oo`3=APo3=HNV z2RK8@gDPi8Hl5)N3OWXcInEG^);dFi{J1m3=hvJW81fhx816bVFnBO9F!;DIFnEEQ zUM>)iJ#c}9%oi7idImdC8_xw2x7Mx@hj>6~KUYW^2yulJ(Fv{)mp8dW66Z2kNZcQF zg~a_iSBL{1xI!HE+!f*>MmLDKxEmx&<=i0I)6lJ+fgy^4fuYb1(gr*Or8V3kaoOU| zz>vzoz##6yz+lF}z)<VKzz_o}Ks+F2zn~`r!!ZU123t>vk6FARZNFkK28I#_28LZ; z3=GB$3=I0-kPu4thD1e?H^c#3>b)V=;TLZP1~rgHK9Iy^=L5;F-98Kqt_%ze`+Oi} zKZ`E|IK;GkA=PfCF9Sm<0|P^=FC;De_Jx!is(#??#bE0PDXP=_7#Jc!O*TIUhD1=? z4@%bu_(Kfd<j=rx1k`-?hcqtd20*gO?*K?pat1=8LOBqUUG)PYL2nWW$sM+V3=B*R z3=AHDkht}R(!o$V8mcZi5E9jSf#7ndo}oIBfuWa?fuTDP5{Ht(kVK*#46(pI7!u?j z!H`6^G#KKLZNU%+9tvh)Fa@=0gCP!K3xUM3U<f26R6-!Rz$OIZk=PJONSB9z(po(O z!@LkkB3Tjwv3OSq1H&0mHVJ{0c(X$xJ~|c(QFksB68BF+AyFp~1_@f@Fi70@hA}Y2 zf!cUskdQSFhvfUna0Z5C1_p+_a0Z4o3=9mH!WkGq>5rj4f`Orkfq~&z1f-g@jD!@C znURnJsU;FpyUmV-wB>e3LRvQ8BN-Ti85kHuq9AD@BMRccmMBOF%!-19%-SeO$UKgM z6w&XZAc^uv6azy&sH>$D4GCJ4XowG7qaktB6V1R72I^`>Lo|Mfh6MSqXo!IvF_5$% z90N(s5;2hKHzEcSGPO{8K@7x0hhrd7d@lwP(jQ_N>cO2!mRN|v+_4Y?Bx4~O>|&uo z7YoTgc~H7G7UH0uSV$U~77Ovf{8&iHY>0(ebT$^^k^4|{-az?`aS(C7xO#|1(s7U? z)hrGYl<9GhqO>^<68ER$AO>EBs(Ts-NrYnY5Lzi75><Nf3=Db<3=FREki=FJ4=FkO z;vrGECLZGRGw~1)J*bZdX9I@!@erT>kB6jIkpxJsXPN*p$S(on(2N8~$=93!>D4ZR z^6w@vFzA3v(ga9Jr<n*TXbch|X{0C-60*k<L8+dB;dLUUb6fv05fYdANsu6^N`m;f zJqhB{{v=3ZTAT!N=(Z$C9dIlOl8sI!K?<0UNem1j3=9nN$&ipLNrsr)mJHE1F&Sds z!emJAcWW}(o_dCN$&k4FmJDeHv!^gH`~Vf1DGUsi3=9k!sSFIO85kHAr9!GrzcfgY zH>5#|<eoH$0}rP`EWVZoNgFTHAR+lFje+41sBxPPamclFi2Sp3P}I~jFnmadWE;K= zND!-JKpbSB0Wmlr0}`~!86XV|3==XS4qXSOuVp~e&c6&uoa$#nXuC{^dA^wtpJ!x3 ziu9UHNV&2;lYv16RR7<~grwU4nUH#3CJW-y+$@MsYqKD2xXvty%a3P4vfafjNb0|q z1<7vrvmg#*%7*Z@vLWS(Z#JYrs?BC#@MB<LxRVVD8SNYfhI&vx-zNuRQD_dNz(~x2 zq~hs0ki>T^2ND7gav&kVn+wS$+PM%1xaUIBPCzb1Uu-VK;+|YcNbJdlc<^*C1H(no zm{BeR!#+^$n8#2L9?PlBha{re`H(m~nGZ=kxAP%GE6<>O=>mwnS^*?GSrkBg5>WsN z;>rR@3E5i!389k(kh<es0VFE53n7Wut`MT$sjwd6!mvV!4^j&u3X2LMl}BqKq#B(I zl|NPp$wrq8ArAgg2+5{uMG$@VMGOq#3=9lGMGOpe3=9mbiXi2JW-$Z9O3;{3F$2Q^ z1_p*##Sjm!t}lTE;kFWp0sBiJ8ZMPUg7RevB%4W;LfUTjr4R=<ltR)-e<`GiHNO<n z;CN9ANxZRT5D(;)LHKQDkaA%<ls^wDU%$Q#(zH5W#=y|Sz`*dbjDcYt0|P@@IiyHd zt$+l%aRnrVTq_`{I;aBTklYGL+}2buFgStgiV8^nzEc57JEE14M5|v3Dc~$CA?Ep2 zf<0Q#kWdM!PRlDHak{t?(#kzt32A&XR6)cosvwCjq6!jpB~_3_TMy-TS3!I@tqPI` z=2k%*cC!kShQ3un9HLzfp-rn97;+iv85r!VAqK6jhSb+Pt06wUUkwT3H`S0h{!<Mu zu^2>aAW<a?r44Ez>YQsB7*;VbFa*~?LhfG;#K%0f5dDg^kPxw|g=Ei=S_TGDQ2sBk zg;?BM3kjOdwU9)1v=)+jU)3@&%wk|*aIb@u*^lZVJ(`4ih>w5PLmbA?0PzS%1H@vH z21vHmY+zuR$-ux6)&ObLerSL=pt+HOp&m4jx2zFTL~d<_bT%uRAaVJkiGjg|fq_B0 z8Pd2cXl7t=W?*2L)y%*U#K6FCuNhJv7`8wx@^4{a@Md6OsA*wf@MK_MIMD(r@q}6- z+0L~Uk}W-3AtkO~D`@<Wk%1wl71FA$Y=dOC9c>T|58EIXJZ*#2dd%$*jY{p1D6(j0 zV8{klx9t%98`~ike`tp!QmYO~S_$o7U<hPjU`XhIgv_Q6NUl5H0S&qf9gv{A(E;)4 zlMYDs<LiVZI@?Z&d}Jr2;7IL+IH0l<QUJAeLK5-vPKbq<J0aQbUMHj-@xGG*+{82Q zf(-Ai>S6$oqKVXZGcarb4G?rg%4oYDNE2*s4+ALSGd$^mR2K2Q3=E$@!}Pt7_I_<2 zq~r3r4>Dxq*AGeM*ZU!9h-U(%NhUV|(w~T%0LcxjCqO)SVgdt$F9QQZ{ig|#YSdvO z#HHmEAtl@Xi4X(cPJ~2(?j%Se@}2~#?Ve78gpAl^NJGPDGQ{AL$q=8;n+&PG1*bsx z-BTbA_&0@t;WGmRgY;AehBVOlAOAFni_4}#;;dsDBxn~;gCv#>(;z{=6-pn3il3bZ zN%fbeLE`cjl>c}dB<NpHgOq@Ora`iu$aDq<233uW{L-Rg%@hSN&7itjPBM~t^G+Fk zPBDej;?yFA+{DZrg@U5gwA7;1yyVp4%@0*qGfwVPSJ?bsT}*IutmOfg%{T2Hif|ZO zDi|4A8JcW96r9Yuxg;`|OC=O!fUB-=VrGu6Yi3??Nor0`X<mA2o<d@FNoH9l$jHqP zl4Y6H!%~ZiOHz}wQx!mp6H|&(i;Gk96mm056jD-i6@pSh&PXlVoRP*Ty7^(XA=_rQ H=5yizY;R>j diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po index 4a00b8e8..39b09746 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-19 22:14+0200\n" +"POT-Creation-Date: 2022-10-20 17:36+0200\n" "PO-Revision-Date: 2021-05-25 21:18+0200\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language: de\n" @@ -140,8 +140,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:57 -#: uffd/templates/service/show.html:85 uffd/templates/user/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 msgid "New" msgstr "Neu" @@ -158,7 +158,7 @@ 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:91 uffd/templates/user/show.html:189 +#: uffd/templates/service/show.html:107 uffd/templates/user/show.html:189 #: uffd/templates/user/show.html:221 msgid "Name" msgstr "Name" @@ -234,7 +234,7 @@ msgid "Created by" msgstr "Erstellt durch" #: uffd/templates/invite/list.html:14 uffd/templates/service/api.html:34 -#: uffd/templates/service/show.html:92 +#: uffd/templates/service/show.html:108 msgid "Permissions" msgstr "Berechtigungen" @@ -1227,7 +1227,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:38 +#: uffd/templates/service/api.html:51 uffd/templates/service/show.html:37 msgid "This option has no effect: Remailer config options are unset" msgstr "Diese Option hat keine Auswirkung: Remailer ist nicht konfiguriert" @@ -1235,7 +1235,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:63 +#: uffd/templates/service/oauth2.html:20 uffd/templates/service/show.html:79 msgid "Client ID" msgstr "Client-ID" @@ -1305,12 +1305,43 @@ msgid "Members of group \"%(group_name)s\" have access" msgstr "Mitglieder der Gruppe \"%(group_name)s\" haben Zugriff" #: uffd/templates/service/show.html:36 -msgid "Hide mail addresses with remailer" -msgstr "Verstecke Mailadressen mit dem Remailer" +msgid "Hide e-mail addresses with remailer" +msgstr "E-Mail-Adressen mit Remailer verstecken" -#: uffd/templates/service/show.html:45 -msgid "Enable user mail preferences" -msgstr "User E-Mail-Einstellungen aktivieren" +#: uffd/templates/service/show.html:41 +msgid "Remailer disabled" +msgstr "Remailer deaktiviert" + +#: uffd/templates/service/show.html:44 +msgid "Remailer enabled" +msgstr "Remailer aktiviert" + +#: uffd/templates/service/show.html:47 +msgid "Remailer enabled (deprecated, case-sensitive format)" +msgstr "" +"Remailer aktiviert (veraltetes, Groß-/Kleinschreibung-unterscheidendes " +"Format)" + +#: uffd/templates/service/show.html:51 +msgid "" +"Some services notify users about changes to their e-mail address. " +"Modifying this setting immediatly affects the e-mail addresses of all " +"users and can cause masses of notification e-mails." +msgstr "" +"Einige Dienste benachrichtigen Nutzer bei Änderungen ihrer E-Mail-" +"Adresse. Diese Einstellung zu verändern wirkt sich unmittelbar auf die E" +"-Mail-Adressen aller Nutzer aus und kann zu massenhaftem Versand von " +"Benachrichtigungs-E-Mails führen." + +#: uffd/templates/service/show.html:58 +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 +msgid "If disabled, the service always uses the primary e-mail address." +msgstr "Wenn deaktiviert, wird immer die primäre E-Mail-Adresse verwendet." #: uffd/templates/session/deviceauth.html:15 msgid "Log into a service on another device without entering your password." @@ -1868,7 +1899,7 @@ msgstr "E-Mail-Einstellungen geändert" msgid "You left role %(role_name)s" msgstr "Rolle %(role_name)s verlassen" -#: uffd/views/service.py:32 +#: uffd/views/service.py:34 msgid "Services" msgstr "Dienste" diff --git a/uffd/utils.py b/uffd/utils.py index ff40711c..dcdf8b08 100644 --- a/uffd/utils.py +++ b/uffd/utils.py @@ -1,5 +1,6 @@ import secrets import math +import base64 def token_with_alphabet(alphabet, nbytes=None): '''Return random text token that consists of characters from `alphabet`''' @@ -18,3 +19,19 @@ def token_urlfriendly(nbytes=None): '''Return random text token that is urlsafe and works around common parsing bugs''' alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' return token_with_alphabet(alphabet, nbytes=nbytes) + +def nopad_b32decode(value): + if isinstance(value, bytes): + value = value.decode() + return base64.b32decode(value + ('=' * (-len(value) % 8))) + +def nopad_b32encode(value): + return base64.b32encode(value).rstrip(b'=') + +def nopad_urlsafe_b64decode(value): + if isinstance(value, bytes): + value = value.decode() + return base64.urlsafe_b64decode(value + ('=' * (-len(value) % 4))) + +def nopad_urlsafe_b64encode(value): + return base64.urlsafe_b64encode(value).rstrip(b'=') diff --git a/uffd/views/service.py b/uffd/views/service.py index a8c9e28d..7f5b36e0 100644 --- a/uffd/views/service.py +++ b/uffd/views/service.py @@ -6,12 +6,14 @@ from flask_babel import lazy_gettext from uffd.navbar import register_navbar from uffd.csrf import csrf_protect from uffd.database import db -from uffd.models import Service, get_services, Group, OAuth2Client, OAuth2LogoutURI, APIClient +from uffd.models import Service, get_services, Group, OAuth2Client, OAuth2LogoutURI, APIClient, RemailerMode from .session import login_required bp = Blueprint('service', __name__, template_folder='templates') +bp.add_app_template_global(RemailerMode, 'RemailerMode') + def admin_acl(): return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP']) @@ -71,7 +73,7 @@ def edit_submit(id=None): else: service.limit_access = True service.access_group = Group.query.get(request.form['access-group']) - service.use_remailer = request.form.get('use_remailer') == '1' + service.remailer_mode = RemailerMode[request.form['remailer-mode']] service.enable_email_preferences = request.form.get('enable_email_preferences') == '1' db.session.commit() return redirect(url_for('service.show', id=service.id)) -- GitLab