diff --git a/tests/test_api.py b/tests/test_api.py index 9200fc898aca7a87cf11fe35c1c569da28718afe..678aa467e8d3b68c7327384ba3a10de04f906c72 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 1dea40742f5d906a2de3bc0728f1626d0fb396da..e47ba99593a7fea1e89d55897730af28410192ac 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 8ef261fc87d8145301b796c7e09557362de84050..239f3743d1669a543213f31a650ca06cc7f023f0 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 14ec4f70225d1bb1c164bf6e5ef10425c63bf32d..5a1848bcc24181c799b08060f4b3cc7f1fc2ac35 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 0000000000000000000000000000000000000000..3d42f4437c4307d8f7c38829fe6c515513418ae5 --- /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 0000000000000000000000000000000000000000..bdf95ccc793e6cd5eb3f50c7a0d6bdd158e67428 --- /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 52d9709b285d4fcacaa837bae570e4af7c79d729..1644e4729cf9bc714d733c93ed870b7b08f06e60 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 9d2b3458894491c8e45ac1038b370837fc8f5239..e6b34eaa45f6c3859a31b16f8dad35cd9676a8f8 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 f6927e28609d810f1fcd8200259c79f470bc368d..c5e5cbcce58674c5c85be9b7b8e0b754d9952880 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 a4b37506ebd8e0b90113f81b04675237502fd43f..f3ffd0792f59ba7c3f6886c92a100f47a845f8a0 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 55a12328148b51333117536500a3e3adaac6b406..6c6fca94f8121b6e06ad228355d995e7c17e54d6 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 Binary files a/uffd/translations/de/LC_MESSAGES/messages.mo and b/uffd/translations/de/LC_MESSAGES/messages.mo differ diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po index 4a00b8e81dc086a597fbc692565a0fcd769537f0..39b097466cd41300c3ed2fb86a27b46082cbd8dc 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 ff40711c3e114736781931aa83ee3c2cc10a20a9..dcdf8b08c63bb611fb63d4fe9f939ca478aa877b 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 a8c9e28dc7941ab2e3d7c7cf65574dca10249047..7f5b36e03aaeeda3e2fb211751895f91bac74fe1 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))