diff --git a/README.md b/README.md index a1a59daaf816d44add0a91aad31f1c9e48e0d5bb..7cb76413b045e0810e59b8f57ff2d54346b9a643 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Please note that we refer to Debian packages here and **not** pip packages. - python3-oauthlib - python3-flask-babel - python3-argon2 +- python3-itsdangerous (also a dependency of python3-flask) - python3-mysqldb or python3-pymysql for MySQL/MariaDB support Some of the dependencies (especially fido2) changed their API in recent versions, so make sure to install the versions from Debian Buster or Bullseye. diff --git a/debian/control b/debian/control index f9fef23b94d2f0720ab7773a2c0aae6ab26c3cdd..ae3744ceb9a28e343fe72422911c36855c2112da 100644 --- a/debian/control +++ b/debian/control @@ -26,6 +26,7 @@ Depends: python3-oauthlib, python3-flask-babel, python3-argon2, + python3-itsdangerous, uwsgi, uwsgi-plugin-python3, Recommends: diff --git a/setup.py b/setup.py index ad1822a8314c01583dede68d10682cc3da49c05f..45f8ca6346f9ea87abcfcedc33ffac5bec9cce28 100644 --- a/setup.py +++ b/setup.py @@ -41,6 +41,7 @@ setup( 'Flask-Babel==0.11.2', 'alembic==1.0.0', 'argon2-cffi==18.3.0', + 'itsdangerous==0.24', # The main dependencies on their own lead to version collisions and pip is # not very good at resolving them, so we pin the versions from Debian Buster @@ -52,7 +53,6 @@ setup( 'click==7.0', 'cryptography==2.6.1', 'idna==2.6', - 'itsdangerous==0.24', 'Jinja2==2.10', 'MarkupSafe==1.1.0', 'oauthlib==2.1.0', diff --git a/tests/test_api.py b/tests/test_api.py index 4d1a0bf7bc56df8cd0031e77b6c4f09c64355566..7e86379a7fa9261aa792140843cee1ae1c64da98 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -5,7 +5,7 @@ from flask import url_for from uffd.api.views import apikey_required from uffd.api.models import APIClient from uffd.service.models import Service -from uffd.user.models import User +from uffd.user.models import User, remailer from uffd.password_hash import PlaintextPasswordHash from uffd.database import db from utils import UffdTestCase, db_flush @@ -140,6 +140,19 @@ class TestAPIGetusers(UffdTestCase): self.assertEqual(r.status_code, 200) self.assertEqual(r.json, []) + def test_with_remailer(self): + service = Service.query.filter_by(name='test').one() + service.use_remailer = True + db.session.commit() + user = self.get_user() + self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' + r = self.client.get(path=url_for('api.getusers', id=10000), 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 User', 'email': remailer.build_address(user, service), 'id': 10000, 'loginname': 'testuser', 'groups': ['uffd_access', 'users']}, + ]) + def test_loginname(self): r = self.client.get(path=url_for('api.getusers', loginname='testuser'), headers=[basic_auth('test', 'test')], follow_redirects=True) self.assertEqual(r.status_code, 200) @@ -159,6 +172,19 @@ class TestAPIGetusers(UffdTestCase): {'displayname': 'Test Admin', 'email': 'admin@example.com', 'id': 10001, 'loginname': 'testadmin', 'groups': ['uffd_access', 'uffd_admin', 'users']} ]) + def test_email_with_remailer(self): + service = Service.query.filter_by(name='test').one() + service.use_remailer = True + 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(user, service)), 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(user, service), 'id': 10001, 'loginname': 'testadmin', 'groups': ['uffd_access', 'uffd_admin', 'users']} + ]) + def test_email_empty(self): r = self.client.get(path=url_for('api.getusers', email='foo@bar'), headers=[basic_auth('test', 'test')], follow_redirects=True) self.assertEqual(r.status_code, 200) @@ -231,3 +257,28 @@ class TestAPIGetgroups(UffdTestCase): r = self.client.get(path=url_for('api.getgroups', member='notauser'), headers=[basic_auth('test', 'test')], follow_redirects=True) self.assertEqual(r.status_code, 200) self.assertEqual(r.json, []) + +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)) + + def test(self): + self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' + service = Service.query.filter_by(name='service2').one() + user = self.get_user() + r = self.client.get(path=url_for('api.resolve_remailer', orig_address=remailer.build_address(user, service)), headers=[basic_auth('test', 'test')], follow_redirects=True) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json, {'address': user.mail}) + r = self.client.get(path=url_for('api.resolve_remailer', orig_address='foo@bar'), headers=[basic_auth('test', 'test')], follow_redirects=True) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json, {'address': None}) + + def test_invalid(self): + r = self.client.get(path=url_for('api.resolve_remailer', orig_address=['foo', 'bar']), headers=[basic_auth('test', 'test')], follow_redirects=True) + self.assertEqual(r.status_code, 400) + r = self.client.get(path=url_for('api.resolve_remailer'), headers=[basic_auth('test', 'test')], follow_redirects=True) + self.assertEqual(r.status_code, 400) + r = self.client.get(path=url_for('api.resolve_remailer', foo='bar'), headers=[basic_auth('test', 'test')], follow_redirects=True) + self.assertEqual(r.status_code, 400) diff --git a/tests/test_oauth2.py b/tests/test_oauth2.py index d3e68dfec7c330d2b44fee1e4d461ccc5616d37b..9021240c3789e46c6483671e6c6df6425ff9b36d 100644 --- a/tests/test_oauth2.py +++ b/tests/test_oauth2.py @@ -6,7 +6,7 @@ from flask import url_for, session # These imports are required, because otherwise we get circular imports?! from uffd import user -from uffd.user.models import User +from uffd.user.models import User, remailer from uffd.password_hash import PlaintextPasswordHash from uffd.session.models import DeviceLoginConfirmation from uffd.service.models import Service @@ -20,7 +20,7 @@ class TestViews(UffdTestCase): db.session.add(OAuth2Client(service=Service(name='test', limit_access=False), client_id='test', client_secret='testsecret', redirect_uris=['http://localhost:5009/callback', 'http://localhost:5009/callback2'])) db.session.add(OAuth2Client(service=Service(name='test1', access_group=self.get_admin_group()), client_id='test1', client_secret='testsecret1', redirect_uris=['http://localhost:5008/callback'])) - def assert_authorization(self, r): + def assert_authorization(self, r, mail=None): while True: if r.status_code != 302 or r.location.startswith('http://localhost:5009/callback'): break @@ -44,7 +44,7 @@ class TestViews(UffdTestCase): self.assertEqual(r.json['id'], user.unix_uid) self.assertEqual(r.json['name'], user.displayname) self.assertEqual(r.json['nickname'], user.loginname) - self.assertEqual(r.json['email'], user.mail) + self.assertEqual(r.json['email'], mail or user.mail) self.assertTrue(r.json.get('groups')) def test_authorization(self): @@ -52,6 +52,16 @@ class TestViews(UffdTestCase): 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) self.assert_authorization(r) + 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 + 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(self.get_user(), service)) + def test_authorization_client_secret_rehash(self): OAuth2Client.query.delete() db.session.add(OAuth2Client(service=Service(name='rehash_test', limit_access=False), client_id='test', client_secret=PlaintextPasswordHash.from_password('testsecret'), redirect_uris=['http://localhost:5009/callback', 'http://localhost:5009/callback2'])) diff --git a/tests/test_user.py b/tests/test_user.py index 6a92ab6b72c99fb92f9b64cb75a31dc2534237ad..0990a74f0bdb1f54e49eef14bafb19892bafe51b 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -7,8 +7,9 @@ import sqlalchemy # These imports are required, because otherwise we get circular imports?! from uffd import user -from uffd.user.models import User, Group +from uffd.user.models import User, remailer, RemailerAddress, Group from uffd.role.models import Role, RoleGroup +from uffd.service.models import Service from uffd import create_app, db from utils import dump, UffdTestCase @@ -98,6 +99,136 @@ class TestUserModel(UffdTestCase): db.session.add(user2) db.session.commit() + def test_set_mail(self): + user = User() + self.assertTrue(user.set_mail('foobar@example.com')) + self.assertEqual(user.mail, 'foobar@example.com') + self.assertFalse(user.set_mail('')) + self.assertEqual(user.mail, 'foobar@example.com') + self.assertFalse(user.set_mail('foobar')) + self.assertFalse(user.set_mail('@')) + self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' + self.assertFalse(user.set_mail('foobar@remailer.example.com')) + self.assertFalse(user.set_mail('v1-1-testuser@remailer.example.com')) + self.assertFalse(user.set_mail('v1-1-testuser @ remailer.example.com')) + self.assertFalse(user.set_mail('v1-1-testuser@REMAILER.example.com')) + self.assertFalse(user.set_mail('v1-1-testuser@foobar@remailer.example.com')) + + def test_get_service_mail(self): + service1 = Service(name='service1') + service2 = Service(name='service2', use_remailer=True) + db.session.add_all([service1, service2]) + db.session.commit() + user = self.get_user() + self.assertEqual(user.get_service_mail(service1), user.mail) + self.assertEqual(user.get_service_mail(service2), user.mail) + self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' + self.assertEqual(user.get_service_mail(service1), user.mail) + self.assertEqual(user.get_service_mail(service2), remailer.build_address(user, service2)) + self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testadmin'] + self.assertEqual(user.get_service_mail(service1), user.mail) + self.assertEqual(user.get_service_mail(service2), user.mail) + self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testadmin', 'testuser'] + self.assertEqual(user.get_service_mail(service1), user.mail) + self.assertEqual(user.get_service_mail(service2), remailer.build_address(user, service2)) + + def test_filter_by_service_mail(self): + service1 = Service(name='service1') + service2 = Service(name='service2', use_remailer=True) + db.session.add_all([service1, service2]) + db.session.commit() + user = self.get_user() + self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, user.mail)).all(), [user]) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, remailer.build_address(user, service1))).all(), []) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, remailer.build_address(user, service2))).all(), []) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, user.mail)).all(), [user]) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, remailer.build_address(user, service1))).all(), []) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, remailer.build_address(user, service2))).all(), []) + self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' + self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, user.mail)).all(), [user]) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, remailer.build_address(user, service1))).all(), []) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, remailer.build_address(user, service2))).all(), []) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, user.mail)).all(), []) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, remailer.build_address(user, service2))).all(), [user]) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, remailer.build_address(user, service1))).all(), []) + self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testadmin'] + self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, user.mail)).all(), [user]) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, remailer.build_address(user, service1))).all(), []) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, remailer.build_address(user, service2))).all(), []) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, user.mail)).all(), [user]) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, remailer.build_address(user, service1))).all(), []) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, remailer.build_address(user, service2))).all(), []) + self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testadmin', 'testuser'] + self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, user.mail)).all(), [user]) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, remailer.build_address(user, service1))).all(), []) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, remailer.build_address(user, service2))).all(), []) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, user.mail)).all(), []) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, remailer.build_address(user, service1))).all(), []) + self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, remailer.build_address(user, service2))).all(), [user]) + +class TestRemailer(UffdTestCase): + def setUpDB(self): + self.service1 = Service(name='service1') + self.service2 = Service(name='service2', use_remailer=True) + db.session.add_all([self.service1, self.service2]) + + def test_is_remailer_domain(self): + self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' + self.assertTrue(remailer.is_remailer_domain('remailer.example.com')) + self.assertTrue(remailer.is_remailer_domain('REMAILER.EXAMPLE.COM')) + self.assertTrue(remailer.is_remailer_domain(' remailer.example.com ')) + self.assertFalse(remailer.is_remailer_domain('other.remailer.example.com')) + self.assertFalse(remailer.is_remailer_domain('example.com')) + self.app.config['REMAILER_OLD_DOMAINS'] = [' OTHER.remailer.example.com '] + self.assertTrue(remailer.is_remailer_domain(' OTHER.remailer.example.com ')) + self.assertTrue(remailer.is_remailer_domain('remailer.example.com')) + self.assertTrue(remailer.is_remailer_domain('other.remailer.example.com')) + self.assertFalse(remailer.is_remailer_domain('example.com')) + + def test_build_address(self): + self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' + user = self.get_user() + self.assertTrue(remailer.build_address(user, self.service1).endswith('@remailer.example.com')) + self.assertTrue(remailer.build_address(user, self.service2).endswith('@remailer.example.com')) + self.assertLessEqual(len(remailer.build_local_part(user, self.service1)), 64) + self.assertLessEqual(len(remailer.build_address(user, self.service1)), 256) + self.assertEqual(remailer.build_address(user, self.service1), remailer.build_address(user, self.service1)) + self.assertNotEqual(remailer.build_address(user, self.service1), remailer.build_address(user, self.service2)) + addr = remailer.build_address(user, self.service1) + self.app.config['REMAILER_OLD_DOMAINS'] = ['old.remailer.example.com'] + self.assertEqual(remailer.build_address(user, self.service1), addr) + self.assertTrue(remailer.build_address(user, self.service1).endswith('@remailer.example.com')) + self.app.config['REMAILER_SECRET_KEY'] = self.app.config['SECRET_KEY'] + self.assertEqual(remailer.build_address(user, self.service1), addr) + self.app.config['REMAILER_SECRET_KEY'] = 'REMAILER-DEBUGKEY' + self.assertNotEqual(remailer.build_address(user, self.service1), addr) + + def test_parse_address(self): + self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' + user = self.get_user() + addr = remailer.build_address(user, self.service2) + # REMAILER_DOMAIN behaviour + self.app.config['REMAILER_DOMAIN'] = None + self.assertIsNone(remailer.parse_address(addr)) + self.assertIsNone(remailer.parse_address('foo@example.com')) + self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' + self.assertEqual(remailer.parse_address(addr), RemailerAddress(user, self.service2)) + 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.app.config['REMAILER_DOMAIN'] = 'new-remailer.example.com' + self.assertIsNone(remailer.parse_address(addr)) + self.app.config['REMAILER_OLD_DOMAINS'] = ['remailer.example.com'] + self.assertEqual(remailer.parse_address(addr), RemailerAddress(user, self.service2)) + # REMAILER_SECRET_KEY behaviour + self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' + self.app.config['REMAILER_OLD_DOMAINS'] = [] + self.assertEqual(remailer.parse_address(addr), RemailerAddress(user, self.service2)) + self.app.config['REMAILER_SECRET_KEY'] = self.app.config['SECRET_KEY'] + self.assertEqual(remailer.parse_address(addr), RemailerAddress(user, self.service2)) + self.app.config['REMAILER_SECRET_KEY'] = 'REMAILER-DEBUGKEY' + self.assertIsNone(remailer.parse_address(addr)) + class TestUserViews(UffdTestCase): def setUp(self): super().setUp() diff --git a/uffd/api/models.py b/uffd/api/models.py index 391a0aed2391c741e5418593e42b44e466b1a483..f770ee1a89ad8743beeef5c27857c0e6f26ea855 100644 --- a/uffd/api/models.py +++ b/uffd/api/models.py @@ -17,6 +17,7 @@ class APIClient(db.Model): perm_users = Column(Boolean(), default=False, nullable=False) perm_checkpassword = Column(Boolean(), default=False, nullable=False) perm_mail_aliases = Column(Boolean(), default=False, nullable=False) + perm_remailer = Column(Boolean(), default=False, nullable=False) @classmethod def permission_exists(cls, name): diff --git a/uffd/api/views.py b/uffd/api/views.py index 1f406d2470f500f84e1764efcf7e682fe83c0d56..33e7122ab2726e219955b03873a7834744fb4d0d 100644 --- a/uffd/api/views.py +++ b/uffd/api/views.py @@ -2,7 +2,7 @@ import functools from flask import Blueprint, jsonify, request, abort -from uffd.user.models import User, Group +from uffd.user.models import User, remailer, Group from uffd.mail.models import Mail, MailReceiveAddress, MailDestinationAddress from uffd.api.models import APIClient from uffd.session.views import login_get_user, login_ratelimit @@ -29,6 +29,7 @@ def apikey_required(permission=None): db.session.commit() if permission is not None and not client.has_permission(permission): return 'Forbidden', 403 + request.api_client = client return func(*args, **kwargs) return decorator return wrapper @@ -67,7 +68,7 @@ def generate_user_dict(user): return { 'id': user.unix_uid, 'loginname': user.loginname, - 'email': user.mail, + 'email': user.get_service_mail(request.api_client.service), 'displayname': user.displayname, 'groups': [group.name for group in user.groups] } @@ -87,7 +88,7 @@ def getusers(): elif key == 'loginname' and len(values) == 1: query = query.filter(User.loginname == values[0]) elif key == 'email' and len(values) == 1: - query = query.filter(User.mail == values[0]) + query = query.filter(User.filter_by_service_mail(request.api_client.service, values[0])) elif key == 'group' and len(values) == 1: query = query.join(User.groups).filter(Group.name == values[0]) else: @@ -141,3 +142,16 @@ def getmails(): else: abort(400) return jsonify([generate_mail_dict(mail) for mail in mails]) + +@bp.route('/resolve-remailer', methods=['GET', 'POST']) +@apikey_required('remailer') +def resolve_remailer(): + if list(request.values.keys()) != ['orig_address']: + abort(400) + values = request.values.getlist('orig_address') + if len(values) != 1: + abort(400) + remailer_address = remailer.parse_address(values[0]) + if not remailer_address: + return jsonify(address=None) + return jsonify(address=remailer_address.user.mail) diff --git a/uffd/default_config.cfg b/uffd/default_config.cfg index 9368ce275acc39db86ac25b12d92a982749d136e..3e25e57675d7438b65cdc1b21352f3d2e90dc859 100644 --- a/uffd/default_config.cfg +++ b/uffd/default_config.cfg @@ -36,6 +36,21 @@ MAIL_PASSWORD='*****' MAIL_USE_STARTTLS=True MAIL_FROM_ADDRESS='foo@bar.com' +# Set to a domain name (e.g. "remailer.example.com") to enable remailer. +# Requires special mail server configuration (see uffd-socketmapd). Can be +# enabled/disabled per-service in the service settings. If enabled, services +# no longer get real user mail addresses but instead special autogenerated +# addresses that are replaced with the real mail addresses by the mail server. +REMAILER_DOMAIN = '' +REMAILER_OLD_DOMAINS = [] +# Secret used for construction and verification of remailer addresses. +# If None, the value of SECRET_KEY is used. +REMAILER_SECRET_KEY = None +# Set to list of user loginnames to limit remailer to a small list of users. +# Useful for debugging. If None remailer is active for all users (if +# configured and enabled for a service). +REMAILER_LIMIT_TO_USERS = None + # Do not enable this on a public service! There is no spam protection implemented at the moment. SELF_SIGNUP=False diff --git a/uffd/migrations/versions/704d1245331c_remailer_setting_and_api_permission.py b/uffd/migrations/versions/704d1245331c_remailer_setting_and_api_permission.py new file mode 100644 index 0000000000000000000000000000000000000000..7a4011280a707cb9afdf9c35404d26e5f58e8172 --- /dev/null +++ b/uffd/migrations/versions/704d1245331c_remailer_setting_and_api_permission.py @@ -0,0 +1,72 @@ +"""remailer setting and api permission + +Revision ID: 704d1245331c +Revises: b9d3f7dac9db +Create Date: 2022-04-19 17:32:52.304313 + +""" +from alembic import op +import sqlalchemy as sa + +revision = '704d1245331c' +down_revision = 'b9d3f7dac9db' +branch_labels = None +depends_on = None + +def upgrade(): + meta = sa.MetaData(bind=op.get_bind()) + api_client = sa.Table('api_client', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('service_id', sa.Integer(), nullable=False), + sa.Column('auth_username', sa.String(length=40), nullable=False), + sa.Column('auth_password', sa.Text(), nullable=False), + sa.Column('perm_users', sa.Boolean(), nullable=False), + sa.Column('perm_checkpassword', sa.Boolean(), nullable=False), + sa.Column('perm_mail_aliases', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_api_client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_api_client')), + sa.UniqueConstraint('auth_username', name=op.f('uq_api_client_auth_username')) + ) + 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.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('api_client', copy_from=api_client) as batch_op: + batch_op.add_column(sa.Column('perm_remailer', sa.Boolean(), nullable=False, default=False)) + with op.batch_alter_table('service', copy_from=service) as batch_op: + batch_op.add_column(sa.Column('use_remailer', sa.Boolean(), nullable=False, default=False)) + +def downgrade(): + meta = sa.MetaData(bind=op.get_bind()) + api_client = sa.Table('api_client', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('service_id', sa.Integer(), nullable=False), + sa.Column('auth_username', sa.String(length=40), nullable=False), + sa.Column('auth_password', sa.Text(), nullable=False), + sa.Column('perm_users', sa.Boolean(), nullable=False), + sa.Column('perm_checkpassword', sa.Boolean(), nullable=False), + sa.Column('perm_mail_aliases', sa.Boolean(), nullable=False), + sa.Column('perm_remailer', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_api_client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_api_client')), + sa.UniqueConstraint('auth_username', name=op.f('uq_api_client_auth_username')) + ) + 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.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.drop_column('use_remailer') + with op.batch_alter_table('api_client', copy_from=api_client) as batch_op: + batch_op.drop_column('perm_remailer') diff --git a/uffd/oauth2/views.py b/uffd/oauth2/views.py index d13fd42da1883319bc7d52c38592d6f3c6e5b1d5..e53bf7ccd28872d16216ea70a9d80f69be06e539 100644 --- a/uffd/oauth2/views.py +++ b/uffd/oauth2/views.py @@ -234,7 +234,7 @@ def userinfo(): id=user.unix_uid, name=user.displayname, nickname=user.loginname, - email=user.mail, + email=user.get_service_mail(request.oauth.client.service), groups=[group.name for group in user.groups] ) diff --git a/uffd/service/models.py b/uffd/service/models.py index 7d3501e19a36091e80597fb4009661e5a07086dc..8584b1b0b709faf12e079439d6c80b5f5885ed81 100644 --- a/uffd/service/models.py +++ b/uffd/service/models.py @@ -23,6 +23,8 @@ 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) + def has_access(self, user): return not self.limit_access or self.access_group in user.groups diff --git a/uffd/service/templates/service/api.html b/uffd/service/templates/service/api.html index 9e0f7a8b3c70bf1997a36dac8d5555ecab4d4f7b..50f9d7f971364f2afa57f4a457fe0e451b65668c 100644 --- a/uffd/service/templates/service/api.html +++ b/uffd/service/templates/service/api.html @@ -44,8 +44,21 @@ <input class="form-check-input" type="checkbox" id="client-perm-mail-aliases" name="perm_mail_aliases" value="1" aria-label="enabled" {{ 'checked' if client.perm_mail_aliases }}> <label class="form-check-label" for="client-perm-mail-aliases"><b>mail_aliases</b>: {{_('Access mail aliases')}}</label> </div> + <div class="form-check"> + <input class="form-check-input" type="checkbox" id="client-perm-remailer" name="perm_remailer" value="1" aria-label="enabled" {{ 'checked' if client.perm_remailer }}> + <label class="form-check-label" for="client-perm-remailer"><b>remailer</b>: {{_('Resolve remailer addresses')}}</label> + {% 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> </div> </form> </div> + +<script> +$(function () { + $('[data-toggle="tooltip"]').tooltip() +}) +</script> {% endblock %} diff --git a/uffd/service/templates/service/show.html b/uffd/service/templates/service/show.html index 0bb892c258ea6c6ca36fa066bef3e34208dca675..c1f075deff8f111d9c647b772a2da66070bcf16f 100644 --- a/uffd/service/templates/service/show.html +++ b/uffd/service/templates/service/show.html @@ -30,6 +30,15 @@ {% 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> + {% 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> + </div> </form> {% if service.id %} @@ -85,7 +94,7 @@ </a> </td> <td> - {% for perm in ['users', 'checkpassword', 'mail_aliases'] if client.has_permission(perm) %} + {% for perm in ['users', 'checkpassword', 'mail_aliases', 'remailer'] if client.has_permission(perm) %} {{ perm }}{{ ',' if not loop.last }} {% endfor %} </td> @@ -98,4 +107,9 @@ </div> +<script> +$(function () { + $('[data-toggle="tooltip"]').tooltip() +}) +</script> {% endblock %} diff --git a/uffd/service/views.py b/uffd/service/views.py index cd01583316a9daa3afbbf334e0e57c77d715db35..7411058d2a3245cba4cb3b4df1626a4a5bef0566 100644 --- a/uffd/service/views.py +++ b/uffd/service/views.py @@ -73,6 +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' db.session.commit() return redirect(url_for('service.show', id=service.id)) @@ -157,6 +158,7 @@ def api_submit(service_id, id=None): client.perm_users = request.form.get('perm_users') == '1' client.perm_checkpassword = request.form.get('perm_checkpassword') == '1' client.perm_mail_aliases = request.form.get('perm_mail_aliases') == '1' + client.perm_remailer = request.form.get('perm_remailer') == '1' db.session.commit() return redirect(url_for('service.show', id=service.id)) diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo index 837caf0baecd3654d28ed65210d9f6a8a1ea2098..b6723ca0244d78a396dc215299589e1d5d40bb87 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 cedf033d87d0713739a4c065b51968b99e62e6d2..9f4f419d66695d464083e44538e1054dd13c4b20 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-03-20 13:07+0100\n" +"POT-Creation-Date: 2022-04-21 14:17+0200\n" "PO-Revision-Date: 2021-05-25 21:18+0200\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language: de\n" @@ -114,8 +114,8 @@ msgstr "Mailversand fehlgeschlagen" #: uffd/invite/templates/invite/list.html:6 #: uffd/mail/templates/mail/list.html:8 uffd/role/templates/role/list.html:8 #: uffd/service/templates/service/index.html:8 -#: uffd/service/templates/service/show.html:41 -#: uffd/service/templates/service/show.html:69 +#: uffd/service/templates/service/show.html:50 +#: uffd/service/templates/service/show.html:78 #: uffd/user/templates/group/list.html:8 uffd/user/templates/user/list.html:8 msgid "New" msgstr "Neu" @@ -130,7 +130,7 @@ msgstr "Erstellt durch" #: uffd/invite/templates/invite/list.html:14 #: uffd/service/templates/service/api.html:34 -#: uffd/service/templates/service/show.html:76 +#: uffd/service/templates/service/show.html:85 msgid "Permissions" msgstr "Berechtigungen" @@ -292,7 +292,7 @@ msgstr "Enthaltene Rollen" #: uffd/selfservice/templates/selfservice/self.html:97 #: uffd/service/templates/service/index.html:14 #: uffd/service/templates/service/show.html:20 -#: uffd/service/templates/service/show.html:75 +#: uffd/service/templates/service/show.html:84 #: uffd/user/templates/group/list.html:15 #: uffd/user/templates/group/show.html:26 #: uffd/user/templates/user/show.html:106 @@ -1054,7 +1054,7 @@ msgstr "Passwort vergessen" #: uffd/selfservice/templates/selfservice/forgot_password.html:9 #: uffd/selfservice/templates/selfservice/self.html:21 -#: uffd/session/templates/session/login.html:9 +#: uffd/session/templates/session/login.html:12 #: uffd/signup/templates/signup/start.html:14 #: uffd/user/templates/user/list.html:18 uffd/user/templates/user/show.html:47 msgid "Login Name" @@ -1118,7 +1118,7 @@ msgid "Update Profile" msgstr "Änderungen speichern" #: uffd/selfservice/templates/selfservice/self.html:44 -#: uffd/session/templates/session/login.html:13 +#: uffd/session/templates/session/login.html:16 #: uffd/signup/templates/signup/start.html:41 #: uffd/user/templates/user/show.html:76 msgid "Password" @@ -1235,8 +1235,17 @@ msgstr "Passwörter von Nutzeraccounts verifizieren" msgid "Access mail aliases" msgstr "Zugriff auf Mail-Weiterleitungen" +#: uffd/service/templates/service/api.html:49 +msgid "Resolve remailer addresses" +msgstr "Auflösen von Remailer-Adressen" + +#: uffd/service/templates/service/api.html:51 +#: uffd/service/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" + #: uffd/service/templates/service/oauth2.html:20 -#: uffd/service/templates/service/show.html:47 +#: uffd/service/templates/service/show.html:56 msgid "Client ID" msgstr "Client-ID" @@ -1262,7 +1271,7 @@ msgstr "" "Eine URI pro Zeile, vorangestellt die mit Leerzeichen getrennte HTTP-" "Methode (GET/POST)" -#: uffd/service/templates/service/overview.html:8 +#: uffd/service/templates/service/overview.html:11 msgid "" "Some services may not be publicly listed! Log in to see all services you " "have access to." @@ -1270,15 +1279,21 @@ msgstr "" "Einige Dienste sind eventuell nicht öffentlich aufgelistet! Melde dich an" " um alle Dienste zu sehen, auf die du Zugriff hast." -#: uffd/service/templates/service/overview.html:44 +#: uffd/service/templates/service/overview.html:15 +#: uffd/session/templates/session/login.html:6 +#: uffd/session/templates/session/login.html:20 uffd/templates/base.html:106 +msgid "Login" +msgstr "Anmelden" + +#: uffd/service/templates/service/overview.html:55 msgid "No access" msgstr "Kein Zugriff" -#: uffd/service/templates/service/overview.html:64 +#: uffd/service/templates/service/overview.html:75 msgid "Manage OAuth2 and API clients" msgstr "OAuth2- und API-Clients verwalten" -#: uffd/service/templates/service/overview.html:84 +#: uffd/service/templates/service/overview.html:95 #: uffd/user/templates/user/list.html:58 uffd/user/templates/user/list.html:79 msgid "Close" msgstr "Schließen" @@ -1300,6 +1315,10 @@ msgstr "Alle Account haben Zugriff (veraltet)" msgid "Members of group \"%(group_name)s\" have access" msgstr "Mitglieder der Gruppe \"%(group_name)s\" haben Zugriff" +#: uffd/service/templates/service/show.html:36 +msgid "Hide mail addresses with remailer" +msgstr "Verstecke Mailadressen mit dem Remailer" + #: uffd/session/views.py:71 #, python-format msgid "" @@ -1414,24 +1433,19 @@ msgstr "" "auf dem anderen Gerät und gib dort den obenstehenden Startcode ein. Geben" " anschließend den Bestätigungscode hier ein." -#: uffd/session/templates/session/login.html:6 -#: uffd/session/templates/session/login.html:17 -msgid "Login" -msgstr "Anmelden" - -#: uffd/session/templates/session/login.html:20 +#: uffd/session/templates/session/login.html:23 msgid "- or -" msgstr "- oder -" -#: uffd/session/templates/session/login.html:22 +#: uffd/session/templates/session/login.html:25 msgid "Login with another device" msgstr "Über anderes Gerät anmelden" -#: uffd/session/templates/session/login.html:27 +#: uffd/session/templates/session/login.html:30 msgid "Register" msgstr "Registrieren" -#: uffd/session/templates/session/login.html:29 +#: uffd/session/templates/session/login.html:32 msgid "Forgot Password?" msgstr "Passwort vergessen?" @@ -1569,7 +1583,7 @@ msgstr "Du bist nicht berechtigt, auf diese Seite zuzugreifen." msgid "Change" msgstr "Ändern" -#: uffd/templates/base.html:135 +#: uffd/templates/base.html:142 msgid "About uffd" msgstr "Über uffd" diff --git a/uffd/user/models.py b/uffd/user/models.py index 900ce743865941dd29ded81a3d0d0c93882e51c7..8ff69508929b1714087c940b25a01871c85a7bf5 100644 --- a/uffd/user/models.py +++ b/uffd/user/models.py @@ -2,6 +2,7 @@ import string import re from flask import current_app, escape +import itsdangerous from flask_babel import lazy_gettext from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Text from sqlalchemy.orm import relationship @@ -97,13 +98,99 @@ class User(db.Model): def set_mail(self, value): if len(value) < 3 or '@' not in value: return False + domain = value.rsplit('@', 1)[-1] + if remailer.is_remailer_domain(domain): + return False self.mail = value return True + def get_service_mail(self, service): + if not remailer.configured or not service.use_remailer: + return self.mail + if current_app.config['REMAILER_LIMIT_TO_USERS'] is not None and \ + self.loginname not in current_app.config['REMAILER_LIMIT_TO_USERS']: + return self.mail + return remailer.build_address(self, service) + + @classmethod + def filter_by_service_mail(cls, service, address): + if not remailer.configured or not service.use_remailer: + return cls.mail == address + remailer_address = remailer.parse_address(address) + if remailer_address and remailer_address.service == service and \ + remailer_address.user.get_service_mail(service) == address: + return cls.id == remailer_address.user.id + if current_app.config['REMAILER_LIMIT_TO_USERS'] is not None: + return db.and_( + db.not_(cls.loginname.in_(current_app.config['REMAILER_LIMIT_TO_USERS'])), + cls.mail == address + ) + return False + # Somehow pylint non-deterministically fails to detect that .update_groups is set in invite.modes def update_groups(self): pass +class RemailerAddress: + def __init__(self, user, service): + self.user = user + self.service = service + + def __eq__(self, remailer_address): + return remailer_address is not None and self.user == remailer_address.user and self.service == remailer_address.service + +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.''' + + # pylint: disable=no-self-use + + @property + def configured(self): + return bool(current_app.config['REMAILER_DOMAIN']) + + def get_serializer(self): + 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, user, service): + return 'v1-' + self.get_serializer().dumps([service.id, user.id]) + + def build_address(self, user, service): + return self.build_local_part(user, service) + '@' + current_app.config['REMAILER_DOMAIN'] + + def is_remailer_domain(self, domain): + domains = {domain.lower().strip() for domain in current_app.config['REMAILER_OLD_DOMAINS']} + if current_app.config['REMAILER_DOMAIN']: + domains.add(current_app.config['REMAILER_DOMAIN'].lower().strip()) + return domain.lower().strip() in domains + + def parse_address(self, address): + # With a top-level import we get circular import problems + # pylint: disable=import-outside-toplevel + from uffd.service.models import Service + if '@' not in address: + return None + local_part, domain = address.rsplit('@', 1) + if not self.is_remailer_domain(domain) or not local_part.startswith('v1-'): + return None + data = local_part[len('v1-'):] + try: + service_id, user_id = self.get_serializer().loads(data) + except itsdangerous.BadData: + return None + service = Service.query.get(service_id) + user = User.query.get(user_id) + if not service or not user: + return None + return RemailerAddress(user, service) + +remailer = Remailer() + 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/user/views_user.py b/uffd/user/views_user.py index 95f8643e625fa404156fafab9297737e28115587..d9a8800b3738b40882babef1ce3f2203ad6627d7 100644 --- a/uffd/user/views_user.py +++ b/uffd/user/views_user.py @@ -12,11 +12,12 @@ from uffd.session import login_required from uffd.role.models import Role from uffd.database import db -from .models import User +from .models import User, remailer bp = Blueprint("user", __name__, template_folder='templates', url_prefix='/user/') bp.add_app_template_global(User, 'User') +bp.add_app_template_global(remailer, 'remailer') def user_acl_check(): return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP'])