diff --git a/README.md b/README.md index 8bca22a345a07c3364b944a5d35167e632fc56db..27b4c8437ec8eeb60a29a75adb1997a8a89360f3 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,17 @@ Upgrading will not perform any write access to the LDAP server. If the config option `ACL_SELFSERVICE_GROUP` is set but not `ACL_ACCESS_GROUP`, make sure to set `ACL_ACCESS_GROUP` to the same value as `ACL_SELFSERVICE_GROUP`, +OAuth2 and API client definitions moved from the config (`OAUTH2_CLIENTS` and `API_CLIENTS_2`) to the database. +The database migration automatically imports clients from the config. +After upgrading the config options should be removed. + +Note that the `login_message` option is no longer supported for OAuth2 clients. +The `required_group` is only correctly imported if it is set to a single group name (or absent). + +Also note that uffd can group OAuth2 and API clients of a service together. +Set the `service_name` key in `OAUTH2_CLIENTS` and `API_CLIENTS_2` items to the same value to group them together. +Without this key the import creates individual service objects for each client. + ## Python Coding Style Conventions PEP 8 without double new lines, tabs instead of spaces and a max line length of 160 characters. diff --git a/check_migrations.py b/check_migrations.py index 9ccf98d6be204efa8a08431735401a5e1aa28c99..70f5d25796ddcf7ea4484f11b9c69469b84dcfc0 100755 --- a/check_migrations.py +++ b/check_migrations.py @@ -13,7 +13,8 @@ from uffd.role.models import Role, RoleGroup from uffd.signup.models import Signup from uffd.invite.models import Invite, InviteGrant, InviteSignup from uffd.session.models import DeviceLoginConfirmation -from uffd.oauth2.models import OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation +from uffd.service.models import Service +from uffd.oauth2.models import OAuth2Client, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation from uffd.selfservice.models import PasswordToken, MailToken def run_test(dburi, revision): @@ -48,9 +49,12 @@ def run_test(dburi, revision): invite.signups.append(InviteSignup(loginname='newuser', displayname='New User', mail='newuser@example.com', password='newpassword')) invite.grants.append(InviteGrant(user=user)) db.session.add(Invite(creator=user, valid_until=datetime.datetime.now())) - db.session.add(OAuth2Grant(user=user, client_id='testclient', code='testcode', redirect_uri='http://example.com/callback', expires=datetime.datetime.now())) - db.session.add(OAuth2Token(user=user, client_id='testclient', token_type='Bearer', access_token='testcode', refresh_token='testcode', expires=datetime.datetime.now())) - db.session.add(OAuth2DeviceLoginInitiation(oauth2_client_id='testclient', confirmations=[DeviceLoginConfirmation(user=user)])) + service = Service(name='testservice', access_group=group) + oauth2_client = OAuth2Client(service=service, client_id='testclient', client_secret='testsecret', redirect_uris=['http://localhost:1234/callback'], logout_uris=[OAuth2LogoutURI(method='GET', uri='http://localhost:1234/callback')]) + db.session.add_all([service, oauth2_client]) + db.session.add(OAuth2Grant(user=user, client=oauth2_client, code='testcode', redirect_uri='http://example.com/callback', expires=datetime.datetime.now())) + db.session.add(OAuth2Token(user=user, client=oauth2_client, token_type='Bearer', access_token='testcode', refresh_token='testcode', expires=datetime.datetime.now())) + db.session.add(OAuth2DeviceLoginInitiation(client=oauth2_client, confirmations=[DeviceLoginConfirmation(user=user)])) db.session.add(PasswordToken(user=user)) db.session.add(MailToken(user=user, newmail='test@example.com')) db.session.commit() diff --git a/tests/test_api.py b/tests/test_api.py index b01a6ff3eef261a1148a98d01742fa2ca007fe89..81f02480fdf52221bfa1ad7b2bed695f0b06a5ee 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,6 +3,8 @@ import base64 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.password_hash import PlaintextPasswordHash from uffd.database import db @@ -13,33 +15,25 @@ def basic_auth(username, password): class TestAPIAuth(UffdTestCase): def setUpApp(self): - self.app.config['API_CLIENTS_2'] = { - 'test1': {'client_secret': 'testsecret1', 'scopes': ['getusers', 'testscope']}, - 'test2': {'client_secret': 'testsecret2'}, - } - @self.app.route('/test/endpoint1') @apikey_required() def testendpoint1(): return 'OK', 200 @self.app.route('/test/endpoint2') - @apikey_required('getusers') + @apikey_required('users') def testendpoint2(): return 'OK', 200 - @self.app.route('/test/endpoint3') - @apikey_required('testscope') - def testendpoint3(): - return 'OK', 200 + def setUpDB(self): + db.session.add(APIClient(service=Service(name='test1'), auth_username='test1', auth_password='testsecret1', perm_users=True)) + db.session.add(APIClient(service=Service(name='test2'), auth_username='test2', auth_password='testsecret2')) def test_basic(self): r = self.client.get(path=url_for('testendpoint1'), headers=[basic_auth('test1', 'testsecret1')], follow_redirects=True) self.assertEqual(r.status_code, 200) r = self.client.get(path=url_for('testendpoint2'), headers=[basic_auth('test1', 'testsecret1')], follow_redirects=True) self.assertEqual(r.status_code, 200) - r = self.client.get(path=url_for('testendpoint3'), headers=[basic_auth('test1', 'testsecret1')], follow_redirects=True) - self.assertEqual(r.status_code, 200) r = self.client.get(path=url_for('testendpoint1'), headers=[basic_auth('test2', 'testsecret2')], follow_redirects=True) self.assertEqual(r.status_code, 200) @@ -52,18 +46,26 @@ class TestAPIAuth(UffdTestCase): def test_basic_missing_scope(self): r = self.client.get(path=url_for('testendpoint2'), headers=[basic_auth('test2', 'testsecret2')], follow_redirects=True) self.assertEqual(r.status_code, 403) - r = self.client.get(path=url_for('testendpoint3'), headers=[basic_auth('test2', 'testsecret2')], follow_redirects=True) - self.assertEqual(r.status_code, 403) def test_no_auth(self): r = self.client.get(path=url_for('testendpoint1'), follow_redirects=True) self.assertEqual(r.status_code, 401) + def test_auth_password_rehash(self): + db.session.add(APIClient(service=Service(name='test3'), auth_username='test3', auth_password=PlaintextPasswordHash.from_password('testsecret3'))) + db.session.commit() + self.assertIsInstance(APIClient.query.filter_by(auth_username='test3').one().auth_password, PlaintextPasswordHash) + r = self.client.get(path=url_for('testendpoint1'), headers=[basic_auth('test3', 'testsecret3')], follow_redirects=True) + self.assertEqual(r.status_code, 200) + api_client = APIClient.query.filter_by(auth_username='test3').one() + self.assertIsInstance(api_client.auth_password, APIClient.auth_password.method_cls) + self.assertTrue(api_client.auth_password.verify('testsecret3')) + r = self.client.get(path=url_for('testendpoint1'), headers=[basic_auth('test3', 'testsecret3')], follow_redirects=True) + self.assertEqual(r.status_code, 200) + class TestAPIGetmails(UffdTestCase): - def setUpApp(self): - self.app.config['API_CLIENTS_2'] = { - 'test': {'client_secret': 'test', 'scopes': ['getmails']}, - } + def setUpDB(self): + db.session.add(APIClient(service=Service(name='test'), auth_username='test', auth_password='test', perm_mail_aliases=True)) def test_lookup(self): r = self.client.get(path=url_for('api.getmails', receive_address='test1@example.com'), headers=[basic_auth('test', 'test')], follow_redirects=True) @@ -84,10 +86,8 @@ class TestAPIGetmails(UffdTestCase): self.assertEqual(r.json, [{'name': 'test', 'receive_addresses': ['test1@example.com', 'test2@example.com'], 'destination_addresses': ['testuser@mail.example.com']}]) class TestAPICheckPassword(UffdTestCase): - def setUpApp(self): - self.app.config['API_CLIENTS_2'] = { - 'test': {'client_secret': 'test', 'scopes': ['checkpassword']}, - } + def setUpDB(self): + db.session.add(APIClient(service=Service(name='test'), auth_username='test', auth_password='test', perm_checkpassword=True)) def test(self): r = self.client.post(path=url_for('api.checkpassword'), data={'loginname': 'testuser', 'password': 'userpassword'}, headers=[basic_auth('test', 'test')]) diff --git a/tests/test_oauth2.py b/tests/test_oauth2.py index 47a97f2ae66b654bb5a495023f7ce5c40f64e900..d3e68dfec7c330d2b44fee1e4d461ccc5616d37b 100644 --- a/tests/test_oauth2.py +++ b/tests/test_oauth2.py @@ -7,46 +7,18 @@ from flask import url_for, session from uffd import user from uffd.user.models import User +from uffd.password_hash import PlaintextPasswordHash from uffd.session.models import DeviceLoginConfirmation +from uffd.service.models import Service from uffd.oauth2.models import OAuth2Client, OAuth2DeviceLoginInitiation from uffd import create_app, db from utils import dump, UffdTestCase - -class TestOAuth2Client(UffdTestCase): - def setUpApp(self): - self.app.config['OAUTH2_CLIENTS'] = { - 'test': {'client_secret': 'testsecret', 'redirect_uris': ['http://localhost:5009/callback', 'http://localhost:5009/callback2']}, - 'test1': {'client_secret': 'testsecret1', 'redirect_uris': ['http://localhost:5008/callback'], 'required_group': 'users'}, - } - - def test_from_id(self): - client = OAuth2Client.from_id('test') - self.assertEqual(client.client_id, 'test') - self.assertEqual(client.client_secret, 'testsecret') - self.assertEqual(client.redirect_uris, ['http://localhost:5009/callback', 'http://localhost:5009/callback2']) - self.assertEqual(client.default_redirect_uri, 'http://localhost:5009/callback') - self.assertEqual(client.default_scopes, ['profile']) - self.assertEqual(client.client_type, 'confidential') - client = OAuth2Client.from_id('test1') - self.assertEqual(client.client_id, 'test1') - self.assertEqual(client.required_group, 'users') - - def test_access_allowed(self): - user = self.get_user() # has 'users' and 'uffd_access' group - admin = self.get_admin() # has 'users', 'uffd_access' and 'uffd_admin' group - client = OAuth2Client('test', '', [''], ['uffd_admin', ['users', 'notagroup']]) - self.assertFalse(client.access_allowed(user)) - self.assertTrue(client.access_allowed(admin)) - # More required_group values are tested by TestUserModel.test_has_permission - class TestViews(UffdTestCase): - def setUpApp(self): - self.app.config['OAUTH2_CLIENTS'] = { - 'test': {'client_secret': 'testsecret', 'redirect_uris': ['http://localhost:5009/callback', 'http://localhost:5009/callback2']}, - 'test1': {'client_secret': 'testsecret1', 'redirect_uris': ['http://localhost:5008/callback'], 'required_group': 'uffd_admin'}, - } + def setUpDB(self): + 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): while True: @@ -80,6 +52,18 @@ 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_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'])) + db.session.commit() + self.assertIsInstance(OAuth2Client.query.filter_by(client_id='test').one().client_secret, PlaintextPasswordHash) + 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) + self.assert_authorization(r) + oauth2_client = OAuth2Client.query.filter_by(client_id='test').one() + self.assertIsInstance(oauth2_client.client_secret, OAuth2Client.client_secret.method_cls) + self.assertTrue(oauth2_client.client_secret.verify('testsecret')) + def test_authorization_without_redirect_uri(self): self.login_as('user') r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', scope='profile'), follow_redirects=False) @@ -133,12 +117,12 @@ class TestViews(UffdTestCase): initiation = OAuth2DeviceLoginInitiation.query.filter_by(id=session['devicelogin_id'], secret=session['devicelogin_secret']).one() self.assertEqual(r.status_code, 200) self.assertFalse(initiation.expired) - self.assertEqual(initiation.oauth2_client_id, 'test') + self.assertEqual(initiation.client.client_id, 'test') self.assertIsNotNone(initiation.description) def test_authorization_devicelogin_auth(self): with self.client.session_transaction() as _session: - initiation = OAuth2DeviceLoginInitiation(oauth2_client_id='test') + initiation = OAuth2DeviceLoginInitiation(client=OAuth2Client.query.filter_by(client_id='test').one()) db.session.add(initiation) confirmation = DeviceLoginConfirmation(initiation=initiation, user=self.get_user()) db.session.add(confirmation) diff --git a/tests/test_services.py b/tests/test_services.py index 89b83fb28d2eb149da770beb35002817e63e76d4..269d1455471c70932c26ff9770d5db5d8c7b0ba6 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -40,14 +40,14 @@ class TestServices(UffdTestCase): ] self.app.config['SERVICES_PUBLIC'] = True - def test_index(self): - r = self.client.get(path=url_for('services.index')) - dump('services_index_public', r) + def test_overview(self): + r = self.client.get(path=url_for('service.overview')) + dump('service_overview_public', r) self.assertEqual(r.status_code, 200) self.assertNotIn(b'https://example.com/', r.data) self.login_as('user') - r = self.client.get(path=url_for('services.index')) - dump('services_index', r) + r = self.client.get(path=url_for('service.overview')) + dump('service_overview', r) self.assertEqual(r.status_code, 200) self.assertIn(b'https://example.com/', r.data) diff --git a/tests/test_session.py b/tests/test_session.py index 711c76b9482bb943c9512053119438a50a9ec145..173adfd13d669e550c71f62e78b7ff0b55a055c1 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -8,7 +8,8 @@ from uffd import user from uffd.session.views import login_required from uffd.session.models import DeviceLoginConfirmation -from uffd.oauth2.models import OAuth2DeviceLoginInitiation +from uffd.service.models import Service +from uffd.oauth2.models import OAuth2Client, OAuth2DeviceLoginInitiation from uffd.user.models import User from uffd.password_hash import PlaintextPasswordHash from uffd import create_app, db @@ -161,10 +162,8 @@ class TestSession(UffdTestCase): self.assertIsNone(request.user) def test_deviceauth(self): - self.app.config['OAUTH2_CLIENTS'] = { - 'test': {'client_secret': 'testsecret', 'redirect_uris': ['http://localhost:5009/callback', 'http://localhost:5009/callback2']}, - } - initiation = OAuth2DeviceLoginInitiation(oauth2_client_id='test') + oauth2_client = OAuth2Client(service=Service(name='test', limit_access=False), client_id='test', client_secret='testsecret', redirect_uris=['http://localhost:5009/callback', 'http://localhost:5009/callback2']) + initiation = OAuth2DeviceLoginInitiation(client=oauth2_client) db.session.add(initiation) db.session.commit() code = initiation.code diff --git a/tests/utils.py b/tests/utils.py index 0fd6fbb85896107ea96c0cac1a183d3255464279..b043f3afe37e05e6b5cdeaf81d12ad2373b722e0 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -87,10 +87,14 @@ class UffdTestCase(unittest.TestCase): db.session.add(testadmin) testmail = Mail(uid='test', receivers=['test1@example.com', 'test2@example.com'], destinations=['testuser@mail.example.com']) db.session.add(testmail) + self.setUpDB() db.session.commit() def setUpApp(self): pass + def setUpDB(self): + pass + def tearDown(self): self.client.__exit__(None, None, None) diff --git a/uffd/__init__.py b/uffd/__init__.py index 03ba6d42569c7f5f311b35889d8197472891772a..8831fbdb73820e87cc14df91a8fa06bb89de1e5d 100644 --- a/uffd/__init__.py +++ b/uffd/__init__.py @@ -18,7 +18,7 @@ from uffd.tasks import cleanup_task from uffd.template_helper import register_template_helper from uffd.navbar import setup_navbar from uffd.secure_redirect import secure_local_redirect -from uffd import user, selfservice, role, mail, session, csrf, mfa, oauth2, services, signup, rolemod, invite, api +from uffd import user, selfservice, role, mail, session, csrf, mfa, oauth2, service, signup, rolemod, invite, api from uffd.user.models import User, Group from uffd.role.models import Role, RoleGroup from uffd.mail.models import Mail @@ -68,7 +68,7 @@ def create_app(test_config=None): # pylint: disable=too-many-locals,too-many-sta register_template_helper(app) # Sort the navbar positions by their blueprint names (from the left) - positions = ["selfservice", "services", "rolemod", "invite", "user", "group", "role", "mail"] + positions = ["selfservice", "service", "rolemod", "invite", "user", "group", "role", "mail"] setup_navbar(app, positions) # We never want to fail here, but at a file access that doesn't work. @@ -85,7 +85,7 @@ def create_app(test_config=None): # pylint: disable=too-many-locals,too-many-sta cleanup_task.init_app(app, db) - for module in [user, selfservice, role, mail, session, csrf, mfa, oauth2, services, rolemod, api, signup, invite]: + for module in [user, selfservice, role, mail, session, csrf, mfa, oauth2, service, rolemod, api, signup, invite]: for bp in module.bp: app.register_blueprint(bp) diff --git a/uffd/api/models.py b/uffd/api/models.py new file mode 100644 index 0000000000000000000000000000000000000000..391a0aed2391c741e5418593e42b44e466b1a483 --- /dev/null +++ b/uffd/api/models.py @@ -0,0 +1,26 @@ +from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Text +from sqlalchemy.orm import relationship + +from uffd.database import db +from uffd.password_hash import PasswordHashAttribute, HighEntropyPasswordHash + +class APIClient(db.Model): + __tablename__ = 'api_client' + id = Column(Integer, primary_key=True, autoincrement=True) + service_id = Column(Integer, ForeignKey('service.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) + service = relationship('Service', back_populates='api_clients') + auth_username = Column(String(40), unique=True, nullable=False) + _auth_password = Column('auth_password', Text(), nullable=False) + auth_password = PasswordHashAttribute('_auth_password', HighEntropyPasswordHash) + + # Permissions are defined by adding an attribute named "perm_NAME" + 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) + + @classmethod + def permission_exists(cls, name): + return hasattr(cls, 'perm_'+name) + + def has_permission(self, name): + return getattr(self, 'perm_' + name) diff --git a/uffd/api/views.py b/uffd/api/views.py index 16446fe79b76c8e9f8f81cc432daef6edaea2653..208b7ff77b5c3ca13e8699676ef6943985cbc5d4 100644 --- a/uffd/api/views.py +++ b/uffd/api/views.py @@ -1,30 +1,34 @@ import functools -import secrets -from flask import Blueprint, jsonify, current_app, request, abort +from flask import Blueprint, jsonify, request, abort from uffd.user.models import User, 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 from uffd.database import db bp = Blueprint('api', __name__, template_folder='templates', url_prefix='/api/v1/') -def apikey_required(scope=None): +def apikey_required(permission=None): # pylint: disable=too-many-return-statements + if permission is not None: + assert APIClient.permission_exists(permission) def wrapper(func): @functools.wraps(func) def decorator(*args, **kwargs): - if request.authorization and request.authorization.password: - if request.authorization.username not in current_app.config['API_CLIENTS_2']: - return 'Unauthorized', 401, {'WWW-Authenticate': ['Basic realm="api"']} - client = current_app.config['API_CLIENTS_2'][request.authorization.username] - if not secrets.compare_digest(request.authorization.password, client['client_secret']): - return 'Unauthorized', 401, {'WWW-Authenticate': ['Basic realm="api"']} - if scope is not None and scope not in client.get('scopes', []): - return 'Forbidden', 403 - else: + if not request.authorization or not request.authorization.password: return 'Unauthorized', 401, {'WWW-Authenticate': ['Basic realm="api"']} + client = APIClient.query.filter_by(auth_username=request.authorization.username).first() + if not client: + return 'Unauthorized', 401, {'WWW-Authenticate': ['Basic realm="api"']} + if not client.auth_password.verify(request.authorization.password): + return 'Unauthorized', 401, {'WWW-Authenticate': ['Basic realm="api"']} + if client.auth_password.needs_rehash: + client.auth_password = request.authorization.password + db.session.commit() + if permission is not None and not client.has_permission(permission): + return 'Forbidden', 403 return func(*args, **kwargs) return decorator return wrapper @@ -34,7 +38,7 @@ def generate_group_dict(group): 'members': [user.loginname for user in group.members]} @bp.route('/getgroups', methods=['GET', 'POST']) -@apikey_required('getusers') +@apikey_required('users') def getgroups(): if len(request.values) > 1: abort(400) @@ -60,7 +64,7 @@ def generate_user_dict(user, all_groups=None): 'groups': [group.name for group in all_groups if user in group.members]} @bp.route('/getusers', methods=['GET', 'POST']) -@apikey_required('getusers') +@apikey_required('users') def getusers(): if len(request.values) > 1: abort(400) @@ -108,7 +112,7 @@ def generate_mail_dict(mail): 'destination_addresses': list(mail.destinations)} @bp.route('/getmails', methods=['GET', 'POST']) -@apikey_required('getmails') +@apikey_required('mail_aliases') def getmails(): if len(request.values) > 1: abort(400) diff --git a/uffd/default_config.cfg b/uffd/default_config.cfg index 718e4933f674ad670546d804131a71c18adf8fb2..6546b0024d95cc9f5665055e7f99e25da6bc95e1 100644 --- a/uffd/default_config.cfg +++ b/uffd/default_config.cfg @@ -51,22 +51,6 @@ SQLALCHEMY_TRACK_MODIFICATIONS=False FOOTER_LINKS=[{"url": "https://example.com", "title": "example"}] -OAUTH2_CLIENTS={ - #'test_client_id' : {'client_secret': 'random_secret', 'redirect_uris': ['https://example.com/oauth']}, - # You can optionally restrict access to users with a certain group. Set 'required_group' to the name a group or a list of group names. - # ... 'required_group': 'test_access_group' ... only allows users with group "test_access_group" access - # ... 'required_group': ['groupa', ['groupb', 'groupc']] ... allows users with group "groupa" as well as users with both "groupb" and "groupc" access - # Set 'login_message' (or suffixed with a language code like 'login_message_de') to display a custom message on the login form. -} - -API_CLIENTS_2={ - #'test_client_id' : {'client_secret': 'random_secret', 'scopes': ['users', 'checkpassword']}, - # Scopes: - # * 'getusers': Retrieve user and group data (endpoints getusers and getgroups) - # * 'checkpassword': Check user login password - # * 'getmails': Retrieve mail aliases -} - # Service overview page (disabled if empty) SERVICES=[ # # Title is mandatory, all other fields are optional. diff --git a/uffd/migrations/versions/b9d3f7dac9db_move_api_and_oauth2_clients_to_db.py b/uffd/migrations/versions/b9d3f7dac9db_move_api_and_oauth2_clients_to_db.py new file mode 100644 index 0000000000000000000000000000000000000000..0a2c47b79c19636ff65418cc89c1212435bedbd8 --- /dev/null +++ b/uffd/migrations/versions/b9d3f7dac9db_move_api_and_oauth2_clients_to_db.py @@ -0,0 +1,309 @@ +"""Move API and OAuth2 clients to DB + +Revision ID: b9d3f7dac9db +Revises: 09d2edcaf0cc +Create Date: 2022-02-17 21:14:00.440057 + +""" +from alembic import op +import sqlalchemy as sa +from flask import current_app + +revision = 'b9d3f7dac9db' +down_revision = '09d2edcaf0cc' +branch_labels = None +depends_on = None + +def upgrade(): + used_service_names = set() + services = {} # name -> limit_access, access_group_name + oauth2_clients = [] # service_name, client_id, client_secret, redirect_uris, logout_uris + api_clients = [] # service_name, auth_username, auth_password, perm_users, perm_checkpassword, perm_mail_aliases + for opts in current_app.config.get('OAUTH2_CLIENTS', {}).values(): + if 'service_name' in opts: + used_service_names.add(opts['service_name']) + for opts in current_app.config.get('API_CLIENTS_2', {}).values(): + if 'service_name' in opts: + used_service_names.add(opts['service_name']) + for client_id, opts in current_app.config.get('OAUTH2_CLIENTS', {}).items(): + if 'client_secret' not in opts: + continue + if 'service_name' in opts: + service_name = opts['service_name'] + else: + service_name = client_id + if service_name in used_service_names: + service_name = 'oauth2_' + service_name + if service_name in used_service_names: + num = 1 + while (service_name + '_%d'%num) in used_service_names: + num += 1 + service_name = service_name + '_%d'%num + if opts.get('required_group') is None: + limit_access = False + access_group_name = None + elif isinstance(opts.get('required_group'), str): + limit_access = True + access_group_name = opts['required_group'] + else: + limit_access = True + access_group_name = None + client_secret = opts['client_secret'] + redirect_uris = opts.get('redirect_uris') or [] + logout_uris = [] + for item in opts.get('logout_urls') or []: + if isinstance(item, str): + logout_uris.append(('GET', item)) + else: + logout_uris.append(item) + used_service_names.add(service_name) + if service_name not in services or services[service_name] == (False, None): + services[service_name] = (limit_access, access_group_name) + elif services[service_name] == (limit_access, access_group_name): + pass + else: + services[service_name] = (True, None) + oauth2_clients.append((service_name, client_id, client_secret, redirect_uris, logout_uris)) + for client_id, opts in current_app.config.get('API_CLIENTS_2', {}).items(): + if 'client_secret' not in opts: + continue + if 'service_name' in opts: + service_name = opts['service_name'] + else: + service_name = 'api_' + client_id + if service_name in used_service_names: + num = 1 + while (service_name + '_%d'%num) in used_service_names: + num += 1 + service_name = service_name + '_%d'%num + auth_username = client_id + auth_password = opts['client_secret'] + perm_users = 'getusers' in opts.get('scopes', []) + perm_checkpassword = 'checkpassword' in opts.get('scopes', []) + perm_mail_aliases = 'getmails' in opts.get('scopes', []) + if service_name not in services: + services[service_name] = (False, None) + api_clients.append((service_name, auth_username, auth_password, perm_users, perm_checkpassword, perm_mail_aliases)) + + meta = sa.MetaData(bind=op.get_bind()) + + service_table = op.create_table('service', + 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')) + ) + group_table = sa.table('group', + sa.column('id'), + sa.column('name'), + ) + for service_name, args in services.items(): + limit_access, access_group_name = args + op.execute(service_table.insert().values(name=service_name, limit_access=limit_access, access_group_id=sa.select([group_table.c.id]).where(group_table.c.name==access_group_name).as_scalar())) + + api_client_table = op.create_table('api_client', + 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')) + ) + for service_name, auth_username, auth_password, perm_users, perm_checkpassword, perm_mail_aliases in api_clients: + op.execute(api_client_table.insert().values(service_id=sa.select([service_table.c.id]).where(service_table.c.name==service_name).as_scalar(), auth_username=auth_username, auth_password=auth_password, perm_users=perm_users, perm_checkpassword=perm_checkpassword, perm_mail_aliases=perm_mail_aliases)) + + oauth2client_table = op.create_table('oauth2client', + sa.Column('db_id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('service_id', sa.Integer(), nullable=False), + sa.Column('client_id', sa.String(length=40), nullable=False), + sa.Column('client_secret', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_oauth2client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('db_id', name=op.f('pk_oauth2client')), + sa.UniqueConstraint('client_id', name=op.f('uq_oauth2client_client_id')) + ) + oauth2logout_uri_table = op.create_table('oauth2logout_uri', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('client_db_id', sa.Integer(), nullable=False), + sa.Column('method', sa.String(length=40), nullable=False), + sa.Column('uri', sa.String(length=255), nullable=False), + sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2logout_uri_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2logout_uri')) + ) + oauth2redirect_uri_table = op.create_table('oauth2redirect_uri', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('client_db_id', sa.Integer(), nullable=False), + sa.Column('uri', sa.String(length=255), nullable=False), + sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2redirect_uri_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2redirect_uri')) + ) + for service_name, client_id, client_secret, redirect_uris, logout_uris in oauth2_clients: + op.execute(oauth2client_table.insert().values(service_id=sa.select([service_table.c.id]).where(service_table.c.name==service_name).as_scalar(), client_id=client_id, client_secret=client_secret)) + for method, uri, in logout_uris: + op.execute(oauth2logout_uri_table.insert().values(client_db_id=sa.select([oauth2client_table.c.db_id]).where(oauth2client_table.c.client_id==client_id).as_scalar(), method=method, uri=uri)) + for uri in redirect_uris: + op.execute(oauth2redirect_uri_table.insert().values(client_db_id=sa.select([oauth2client_table.c.db_id]).where(oauth2client_table.c.client_id==client_id).as_scalar(), uri=uri)) + + with op.batch_alter_table('device_login_initiation', schema=None) as batch_op: + batch_op.add_column(sa.Column('oauth2_client_db_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_device_login_initiation_oauth2_client_db_id_oauth2client'), 'oauth2client', ['oauth2_client_db_id'], ['db_id'], onupdate='CASCADE', ondelete='CASCADE') + device_login_initiation_table = sa.Table('device_login_initiation', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('type', sa.Enum('OAUTH2', name='devicelogintype'), nullable=False), + sa.Column('code0', sa.String(length=32), nullable=False), + sa.Column('code1', sa.String(length=32), nullable=False), + sa.Column('secret', sa.String(length=128), nullable=False), + sa.Column('created', sa.DateTime(), nullable=False), + sa.Column('oauth2_client_id', sa.String(length=40), nullable=True), + sa.Column('oauth2_client_db_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['oauth2_client_db_id'], ['oauth2client.db_id'], name=op.f('fk_device_login_initiation_oauth2_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_device_login_initiation')), + sa.UniqueConstraint('code0', name=op.f('uq_device_login_initiation_code0')), + sa.UniqueConstraint('code1', name=op.f('uq_device_login_initiation_code1')) + ) + op.execute(device_login_initiation_table.update().values(oauth2_client_db_id=sa.select([oauth2client_table.c.db_id]).where(device_login_initiation_table.c.oauth2_client_id==oauth2client_table.c.client_id).as_scalar())) + op.execute(device_login_initiation_table.delete().where(device_login_initiation_table.c.oauth2_client_db_id==None)) + with op.batch_alter_table('device_login_initiation', copy_from=device_login_initiation_table) as batch_op: + batch_op.drop_column('oauth2_client_id') + + with op.batch_alter_table('oauth2grant', schema=None) as batch_op: + batch_op.add_column(sa.Column('client_db_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_oauth2grant_client_db_id_oauth2client'), 'oauth2client', ['client_db_id'], ['db_id'], onupdate='CASCADE', ondelete='CASCADE') + oauth2grant_table = sa.Table('oauth2grant', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('client_id', sa.String(length=40), nullable=False), + sa.Column('client_db_id', sa.Integer(), nullable=True), + sa.Column('code', sa.String(length=255), nullable=False), + sa.Column('redirect_uri', sa.String(length=255), nullable=False), + sa.Column('expires', sa.DateTime(), nullable=False), + sa.Column('_scopes', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oauth2grant_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2grant_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2grant')), + sa.Index('ix_oauth2grant_code', 'code') + ) + op.execute(oauth2grant_table.update().values(client_db_id=sa.select([oauth2client_table.c.db_id]).where(oauth2grant_table.c.client_id==oauth2client_table.c.client_id).as_scalar())) + op.execute(oauth2grant_table.delete().where(oauth2grant_table.c.client_db_id==None)) + with op.batch_alter_table('oauth2grant', copy_from=oauth2grant_table) as batch_op: + batch_op.alter_column('client_db_id', existing_type=sa.Integer(), nullable=False) + batch_op.drop_column('client_id') + + with op.batch_alter_table('oauth2token', schema=None) as batch_op: + batch_op.add_column(sa.Column('client_db_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_oauth2token_client_db_id_oauth2client'), 'oauth2client', ['client_db_id'], ['db_id'], onupdate='CASCADE', ondelete='CASCADE') + oauth2token_table = sa.Table('oauth2token', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('client_id', sa.String(length=40), nullable=False), + sa.Column('client_db_id', sa.Integer(), nullable=True), + sa.Column('token_type', sa.String(length=40), nullable=False), + sa.Column('access_token', sa.String(length=255), nullable=False), + sa.Column('refresh_token', sa.String(length=255), nullable=False), + sa.Column('expires', sa.DateTime(), nullable=False), + sa.Column('_scopes', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oauth2token_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2token_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2token')), + sa.UniqueConstraint('access_token', name=op.f('uq_oauth2token_access_token')), + sa.UniqueConstraint('refresh_token', name=op.f('uq_oauth2token_refresh_token')) + ) + op.execute(oauth2token_table.update().values(client_db_id=sa.select([oauth2client_table.c.db_id]).where(oauth2token_table.c.client_id==oauth2client_table.c.client_id).as_scalar())) + op.execute(oauth2token_table.delete().where(oauth2token_table.c.client_db_id==None)) + with op.batch_alter_table('oauth2token', copy_from=oauth2token_table) as batch_op: + batch_op.alter_column('client_db_id', existing_type=sa.Integer(), nullable=False) + batch_op.drop_column('client_id') + +def downgrade(): + meta = sa.MetaData(bind=op.get_bind()) + oauth2client_table = sa.Table('oauth2client', meta, + sa.Column('db_id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('service_id', sa.Integer(), nullable=False), + sa.Column('client_id', sa.String(length=40), nullable=False), + sa.Column('client_secret', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_oauth2client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('db_id', name=op.f('pk_oauth2client')), + sa.UniqueConstraint('client_id', name=op.f('uq_oauth2client_client_id')) + ) + + with op.batch_alter_table('oauth2token', schema=None) as batch_op: + batch_op.add_column(sa.Column('client_id', sa.VARCHAR(length=40), nullable=True)) + oauth2token_table = sa.Table('oauth2token', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('client_id', sa.String(length=40), nullable=True), + sa.Column('client_db_id', sa.Integer(), nullable=False), + sa.Column('token_type', sa.String(length=40), nullable=False), + sa.Column('access_token', sa.String(length=255), nullable=False), + sa.Column('refresh_token', sa.String(length=255), nullable=False), + sa.Column('expires', sa.DateTime(), nullable=False), + sa.Column('_scopes', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oauth2token_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2token_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2token')), + sa.UniqueConstraint('access_token', name=op.f('uq_oauth2token_access_token')), + sa.UniqueConstraint('refresh_token', name=op.f('uq_oauth2token_refresh_token')) + ) + op.execute(oauth2token_table.update().values(client_id=sa.select([oauth2client_table.c.client_id]).where(oauth2token_table.c.client_db_id==oauth2client_table.c.db_id).as_scalar())) + op.execute(oauth2token_table.delete().where(oauth2token_table.c.client_id==None)) + with op.batch_alter_table('oauth2token', copy_from=oauth2token_table) as batch_op: + batch_op.alter_column('client_id', existing_type=sa.VARCHAR(length=40), nullable=False) + batch_op.drop_constraint(batch_op.f('fk_oauth2token_client_db_id_oauth2client'), type_='foreignkey') + batch_op.drop_column('client_db_id') + + with op.batch_alter_table('oauth2grant', schema=None) as batch_op: + batch_op.add_column(sa.Column('client_id', sa.VARCHAR(length=40), nullable=True)) + oauth2grant_table = sa.Table('oauth2grant', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('client_id', sa.String(length=40), nullable=True), + sa.Column('client_db_id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=255), nullable=False), + sa.Column('redirect_uri', sa.String(length=255), nullable=False), + sa.Column('expires', sa.DateTime(), nullable=False), + sa.Column('_scopes', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oauth2grant_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2grant_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2grant')), + sa.Index('ix_oauth2grant_code', 'code') + ) + op.execute(oauth2grant_table.update().values(client_id=sa.select([oauth2client_table.c.client_id]).where(oauth2grant_table.c.client_db_id==oauth2client_table.c.db_id).as_scalar())) + op.execute(oauth2grant_table.delete().where(oauth2grant_table.c.client_id==None)) + with op.batch_alter_table('oauth2grant', copy_from=oauth2grant_table) as batch_op: + batch_op.alter_column('client_id', existing_type=sa.VARCHAR(length=40), nullable=False) + batch_op.drop_constraint(batch_op.f('fk_oauth2grant_client_db_id_oauth2client'), type_='foreignkey') + batch_op.drop_column('client_db_id') + + with op.batch_alter_table('device_login_initiation', schema=None) as batch_op: + batch_op.add_column(sa.Column('oauth2_client_id', sa.VARCHAR(length=40), nullable=True)) + device_login_initiation_table = sa.Table('device_login_initiation', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('type', sa.Enum('OAUTH2', name='devicelogintype'), nullable=False), + sa.Column('code0', sa.String(length=32), nullable=False), + sa.Column('code1', sa.String(length=32), nullable=False), + sa.Column('secret', sa.String(length=128), nullable=False), + sa.Column('created', sa.DateTime(), nullable=False), + sa.Column('oauth2_client_id', sa.String(length=40), nullable=True), + sa.Column('oauth2_client_db_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['oauth2_client_db_id'], ['oauth2client.db_id'], name=op.f('fk_device_login_initiation_oauth2_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_device_login_initiation')), + sa.UniqueConstraint('code0', name=op.f('uq_device_login_initiation_code0')), + sa.UniqueConstraint('code1', name=op.f('uq_device_login_initiation_code1')) + ) + op.execute(device_login_initiation_table.update().values(oauth2_client_id=sa.select([oauth2client_table.c.client_id]).where(device_login_initiation_table.c.oauth2_client_db_id==oauth2client_table.c.db_id).as_scalar())) + op.execute(device_login_initiation_table.delete().where(device_login_initiation_table.c.oauth2_client_id==None)) + with op.batch_alter_table('device_login_initiation', copy_from=device_login_initiation_table) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_device_login_initiation_oauth2_client_db_id_oauth2client'), type_='foreignkey') + batch_op.drop_column('oauth2_client_db_id') + + op.drop_table('oauth2redirect_uri') + op.drop_table('oauth2logout_uri') + op.drop_table('oauth2client') + op.drop_table('api_client') + op.drop_table('service') diff --git a/uffd/oauth2/models.py b/uffd/oauth2/models.py index bae0f7ceec35aebf2067edcd13bed5ddd4d5ddd7..4621f1146130d2a6a43c5a45e6fe2c0d5643b742 100644 --- a/uffd/oauth2/models.py +++ b/uffd/oauth2/models.py @@ -1,47 +1,61 @@ import datetime -from flask import current_app -from flask_babel import get_locale, gettext as _ -from sqlalchemy import Column, Integer, String, DateTime, ForeignKey +from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey from sqlalchemy.orm import relationship from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.ext.associationproxy import association_proxy from uffd.database import db, CommaSeparatedList from uffd.tasks import cleanup_task +from uffd.password_hash import PasswordHashAttribute, HighEntropyPasswordHash from uffd.session.models import DeviceLoginInitiation, DeviceLoginType -class OAuth2Client: - def __init__(self, client_id, client_secret, redirect_uris, required_group=None, logout_urls=None, **kwargs): - self.client_id = client_id - self.client_secret = client_secret - # We only support the Authorization Code Flow for confidential (server-side) clients - self.client_type = 'confidential' - self.redirect_uris = redirect_uris - self.default_scopes = ['profile'] - self.required_group = required_group - self.logout_urls = [] - for url in (logout_urls or []): - if isinstance(url, str): - self.logout_urls.append(['GET', url]) - else: - self.logout_urls.append(url) - self.kwargs = kwargs +class OAuth2Client(db.Model): + __tablename__ = 'oauth2client' + # Inconsistently named "db_id" instead of "id" because of the naming conflict + # with "client_id" in the OAuth2 standard + db_id = Column(Integer, primary_key=True, autoincrement=True) + + service_id = Column(Integer, ForeignKey('service.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) + service = relationship('Service', back_populates='oauth2_clients') + + client_id = Column(String(40), unique=True, nullable=False) + _client_secret = Column('client_secret', Text(), nullable=False) + client_secret = PasswordHashAttribute('_client_secret', HighEntropyPasswordHash) + _redirect_uris = relationship('OAuth2RedirectURI', cascade='all, delete-orphan') + redirect_uris = association_proxy('_redirect_uris', 'uri') + logout_uris = relationship('OAuth2LogoutURI', cascade='all, delete-orphan') @property - def login_message(self): - return self.kwargs.get('login_message_' + get_locale().language, - self.kwargs.pop('login_message', _('You need to login to access this service'))) + def client_type(self): + return 'confidential' - @classmethod - def from_id(cls, client_id): - return OAuth2Client(client_id, **current_app.config['OAUTH2_CLIENTS'][client_id]) + @property + def default_scopes(self): + return ['profile'] @property def default_redirect_uri(self): return self.redirect_uris[0] def access_allowed(self, user): - return user.has_permission(self.required_group) + return self.service.has_access(user) + +class OAuth2RedirectURI(db.Model): + __tablename__ = 'oauth2redirect_uri' + id = Column(Integer, primary_key=True, autoincrement=True) + client_db_id = Column(Integer, ForeignKey('oauth2client.db_id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) + uri = Column(String(255), nullable=False) + + def __init__(self, uri): + self.uri = uri + +class OAuth2LogoutURI(db.Model): + __tablename__ = 'oauth2logout_uri' + id = Column(Integer, primary_key=True, autoincrement=True) + client_db_id = Column(Integer, ForeignKey('oauth2client.db_id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) + method = Column(String(40), nullable=False, default='GET') + uri = Column(String(255), nullable=False) @cleanup_task.delete_by_attribute('expired') class OAuth2Grant(db.Model): @@ -51,15 +65,8 @@ class OAuth2Grant(db.Model): user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) user = relationship('User') - client_id = Column(String(40), nullable=False) - - @property - def client(self): - return OAuth2Client.from_id(self.client_id) - - @client.setter - def client(self, newclient): - self.client_id = newclient.client_id + client_db_id = Column(Integer, ForeignKey('oauth2client.db_id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) + client = relationship('OAuth2Client') code = Column(String(255), index=True, nullable=False) redirect_uri = Column(String(255), nullable=False) @@ -80,15 +87,8 @@ class OAuth2Token(db.Model): user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) user = relationship('User') - client_id = Column(String(40), nullable=False) - - @property - def client(self): - return OAuth2Client.from_id(self.client_id) - - @client.setter - def client(self, newclient): - self.client_id = newclient.client_id + client_db_id = Column(Integer, ForeignKey('oauth2client.db_id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) + client = relationship('OAuth2Client') # currently only bearer is supported token_type = Column(String(40), nullable=False) @@ -109,12 +109,9 @@ class OAuth2DeviceLoginInitiation(DeviceLoginInitiation): __mapper_args__ = { 'polymorphic_identity': DeviceLoginType.OAUTH2 } - oauth2_client_id = Column(String(40)) - - @property - def oauth2_client(self): - return OAuth2Client.from_id(self.oauth2_client_id) + client_db_id = Column('oauth2_client_db_id', Integer, ForeignKey('oauth2client.db_id', onupdate='CASCADE', ondelete='CASCADE')) + client = relationship('OAuth2Client') @property def description(self): - return self.oauth2_client.client_id + return self.client.service.name diff --git a/uffd/oauth2/views.py b/uffd/oauth2/views.py index b52e3600f600f463595f4bf4738aa8bed1ac2228..cfb089b3aeace41393a2da15cd8b0c8e891b4d4a 100644 --- a/uffd/oauth2/views.py +++ b/uffd/oauth2/views.py @@ -22,11 +22,8 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator): # before anything else. authenticate_client_id would be called instead of authenticate_client for non-confidential # clients. However, we don't support those. def validate_client_id(self, client_id, oauthreq, *args, **kwargs): - try: - oauthreq.client = OAuth2Client.from_id(client_id) - return True - except KeyError: - return False + oauthreq.client = OAuth2Client.query.filter_by(client_id=client_id).one_or_none() + return oauthreq.client is not None def authenticate_client(self, oauthreq, *args, **kwargs): authorization = oauthreq.extra_credentials.get('authorization') @@ -41,11 +38,15 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator): oauthreq.client_secret = urllib.parse.unquote(authorization.password) if oauthreq.client_secret is None: return False - try: - oauthreq.client = OAuth2Client.from_id(oauthreq.client_id) - except KeyError: + oauthreq.client = OAuth2Client.query.filter_by(client_id=oauthreq.client_id).one_or_none() + if oauthreq.client is None: return False - return secrets.compare_digest(oauthreq.client.client_secret, oauthreq.client_secret) + if not oauthreq.client.client_secret.verify(oauthreq.client_secret): + return False + if oauthreq.client.client_secret.needs_rehash: + oauthreq.client.client_secret = oauthreq.client_secret + db.session.commit() + return True def get_default_redirect_uri(self, client_id, oauthreq, *args, **kwargs): return oauthreq.client.default_redirect_uri @@ -65,7 +66,7 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator): return set(scopes).issubset({'profile'}) def save_authorization_code(self, client_id, code, oauthreq, *args, **kwargs): - grant = OAuth2Grant(user=oauthreq.user, client_id=client_id, code=code['code'], + grant = OAuth2Grant(user=oauthreq.user, client=oauthreq.client, code=code['code'], redirect_uri=oauthreq.redirect_uri, scopes=oauthreq.scopes) db.session.add(grant) db.session.commit() @@ -80,7 +81,7 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator): return False grant_id, grant_code = code.split('-', 2) oauthreq.grant = OAuth2Grant.query.get(grant_id) - if not oauthreq.grant or oauthreq.grant.client_id != client_id: + if not oauthreq.grant or oauthreq.grant.client != client: return False if not secrets.compare_digest(oauthreq.grant.code, grant_code): return False @@ -91,13 +92,13 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator): return True def invalidate_authorization_code(self, client_id, code, oauthreq, *args, **kwargs): - OAuth2Grant.query.filter_by(client_id=client_id, code=code).delete() + OAuth2Grant.query.filter_by(client=oauthreq.client, code=code).delete() db.session.commit() def save_bearer_token(self, token_data, oauthreq, *args, **kwargs): tok = OAuth2Token( user=oauthreq.user, - client_id=oauthreq.client.client_id, + client=oauthreq.client, token_type=token_data['token_type'], access_token=token_data['access_token'], refresh_token=token_data['refresh_token'], @@ -138,7 +139,7 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator): oauthreq.user = tok.user oauthreq.scopes = scopes oauthreq.client = tok.client - oauthreq.client_id = tok.client_id + oauthreq.client_id = oauthreq.client.client_id return True # get_original_scopes/validate_refresh_token are only used for refreshing tokens. We don't implement the refresh endpoint. @@ -157,7 +158,7 @@ def handle_oauth2error(error): @bp.route('/authorize', methods=['GET', 'POST']) def authorize(): scopes, credentials = server.validate_authorization_request(request.url, request.method, request.form, request.headers) - client = OAuth2Client.from_id(credentials['client_id']) + client = OAuth2Client.query.filter_by(client_id=credentials['client_id']).one() if request.user: credentials['user'] = request.user @@ -168,7 +169,7 @@ def authorize(): flash(_('We received too many requests from your ip address/network! Please wait at least %(delay)s.', delay=format_delay(host_delay))) return redirect(url_for('session.login', ref=request.full_path, devicelogin=True)) host_ratelimit.log() - initiation = OAuth2DeviceLoginInitiation(oauth2_client_id=client.client_id) + initiation = OAuth2DeviceLoginInitiation(client=client) db.session.add(initiation) try: db.session.commit() @@ -180,7 +181,7 @@ def authorize(): return redirect(url_for('session.devicelogin', ref=request.full_path)) elif 'devicelogin_id' in session and 'devicelogin_secret' in session and 'devicelogin_confirmation' in session: initiation = OAuth2DeviceLoginInitiation.query.filter_by(id=session['devicelogin_id'], secret=session['devicelogin_secret'], - oauth2_client_id=client.client_id).one_or_none() + client=client).one_or_none() confirmation = DeviceLoginConfirmation.query.get(session['devicelogin_confirmation']) del session['devicelogin_id'] del session['devicelogin_secret'] @@ -192,14 +193,14 @@ def authorize(): db.session.delete(initiation) db.session.commit() else: - flash(client.login_message) + flash(_('You need to login to access this service')) return redirect(url_for('session.login', ref=request.full_path, devicelogin=True)) # Here we would normally ask the user, if he wants to give the requesting # service access to his data. Since we only have trusted services (the # clients defined in the server config), we don't ask for consent. if not client.access_allowed(credentials['user']): - abort(403, description=_("You don't have the permission to access the service <b>%(service_name)s</b>.", service_name=client.client_id)) + abort(403, description=_("You don't have the permission to access the service <b>%(service_name)s</b>.", service_name=client.service.name)) session['oauth2-clients'] = session.get('oauth2-clients', []) if client.client_id not in session['oauth2-clients']: session['oauth2-clients'].append(client.client_id) @@ -248,5 +249,5 @@ def logout(): if not request.values.get('client_ids'): return secure_local_redirect(request.values.get('ref', '/')) client_ids = request.values['client_ids'].split(',') - clients = [OAuth2Client.from_id(client_id) for client_id in client_ids] + clients = [OAuth2Client.query.filter_by(name=client_id).one() for client_id in client_ids] return render_template('oauth2/logout.html', clients=clients) diff --git a/uffd/selfservice/templates/selfservice/self.html b/uffd/selfservice/templates/selfservice/self.html index d505d2bbcdc2fb56ffa0d807f4441391542b6520..0765b3c3668174e4ed949d715cf3a34ab48a2778 100644 --- a/uffd/selfservice/templates/selfservice/self.html +++ b/uffd/selfservice/templates/selfservice/self.html @@ -86,7 +86,7 @@ <h5>{{_("Roles")}}</h5> <p>{{_("Aside from a set of base permissions, your roles determine the permissions of your account.")}}</p> {% if config['SERVICES'] %} - <p>{{_("See <a href=\"%(services_url)s\">Services</a> for an overview of your current permissions.", services_url=url_for('services.index'))}}</p> + <p>{{_("See <a href=\"%(services_url)s\">Services</a> for an overview of your current permissions.", services_url=url_for('service.overview'))}}</p> {% endif %} </div> <div class="col-12 col-md-7"> diff --git a/uffd/services/__init__.py b/uffd/service/__init__.py similarity index 100% rename from uffd/services/__init__.py rename to uffd/service/__init__.py diff --git a/uffd/services/views.py b/uffd/service/models.py similarity index 64% rename from uffd/services/views.py rename to uffd/service/models.py index 3423eba466fd77843e093ff77fd1d09abb84930f..7d3501e19a36091e80597fb4009661e5a07086dc 100644 --- a/uffd/services/views.py +++ b/uffd/service/models.py @@ -1,9 +1,37 @@ -from flask import Blueprint, render_template, current_app, abort, request -from flask_babel import lazy_gettext, get_locale +from flask import current_app +from flask_babel import get_locale +from sqlalchemy import Column, Integer, String, ForeignKey, Boolean +from sqlalchemy.orm import relationship -from uffd.navbar import register_navbar +from uffd.database import db -bp = Blueprint("services", __name__, template_folder='templates', url_prefix='/services') +class Service(db.Model): + __tablename__ = 'service' + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(255), unique=True, nullable=False) + + # If limit_access is False, all users have access and access_group is + # ignored. This attribute exists for legacy API and OAuth2 clients that + # were migrated from config definitions where a missing "required_group" + # parameter meant no access restrictions. Representing this state by + # setting access_group_id to NULL would lead to a bad/unintuitive ondelete + # behaviour. + limit_access = Column(Boolean(), default=True, nullable=False) + access_group_id = Column(Integer(), ForeignKey('group.id', onupdate='CASCADE', ondelete='SET NULL'), nullable=True) + access_group = relationship('Group') + + oauth2_clients = relationship('OAuth2Client', back_populates='service', cascade='all, delete-orphan') + api_clients = relationship('APIClient', back_populates='service', cascade='all, delete-orphan') + + def has_access(self, user): + return not self.limit_access or self.access_group in user.groups + +# The user-visible services show on the service overview page are read from +# the SERVICES config key. It is planned to gradually extend the Service model +# in order to finally replace the config-defined services. + +def get_language_specific(data, field_name, default =''): + return data.get(field_name + '_' + get_locale().language, data.get(field_name, default)) # pylint: disable=too-many-branches def get_services(user=None): @@ -72,24 +100,3 @@ def get_services(user=None): service['links'].append(link_data) services.append(service) return services - -def get_language_specific(data, field_name, default =''): - return data.get(field_name + '_' + get_locale().language, data.get(field_name, default)) - -def services_visible(): - return len(get_services(request.user)) > 0 - -@bp.route("/") -@register_navbar(lazy_gettext('Services'), icon='sitemap', blueprint=bp, visible=services_visible) -def index(): - services = get_services(request.user) - if not current_app.config['SERVICES']: - abort(404) - - banner = current_app.config.get('SERVICES_BANNER') - - # Set the banner to None if it is not public and no user is logged in - if not (current_app.config["SERVICES_BANNER_PUBLIC"] or request.user): - banner = None - - return render_template('services/overview.html', user=request.user, services=services, banner=banner) diff --git a/uffd/service/templates/service/api.html b/uffd/service/templates/service/api.html new file mode 100644 index 0000000000000000000000000000000000000000..9e0f7a8b3c70bf1997a36dac8d5555ecab4d4f7b --- /dev/null +++ b/uffd/service/templates/service/api.html @@ -0,0 +1,51 @@ +{% extends 'base.html' %} + +{% block body %} +<div class="row"> + <form action="{{ url_for('service.api_submit', service_id=service.id, id=client.id) }}" method="POST" class="form col-12 px-0"> + + <div class="form-group col"> + <p class="text-right"> + <a href="{{ url_for('service.show', id=service.id) }}" class="btn btn-secondary">{{ _('Cancel') }}</a> + {% if client.id %} + <a class="btn btn-danger" href="{{ url_for('service.api_delete', service_id=service.id, id=client.id) }}" onClick='return confirm({{_("Are you sure?")|tojson}});'> + <i class="fa fa-trash" aria-hidden="true"></i> {{_('Delete')}} + </a> + {% endif %} + <button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> {{ _('Save') }}</button> + </p> + </div> + + <div class="form-group col"> + <label for="client-auth-username">{{ _('Authentication Username') }}</label> + <input type="text" class="form-control" id="client-auth-username" name="auth_username" value="{{ client.auth_username or '' }}" required> + </div> + + <div class="form-group col"> + <label for="client-auth-password">{{ _('Authentication Password') }}</label> + {% if client.id %} + <input type="password" class="form-control" id="client-auth-password" name="auth_password" placeholder="●●●●●●●●"> + {% else %} + <input type="password" class="form-control" id="client-auth-password" name="auth_password" required> + {% endif %} + </div> + + <div class="form-group col"> + <h6>{{ _('Permissions') }}</h6> + <div class="form-check"> + <input class="form-check-input" type="checkbox" id="client-perm-users" name="perm_users" value="1" aria-label="enabled" {{ 'checked' if client.perm_users }}> + <label class="form-check-label" for="client-perm-users"><b>users</b>: {{_('Access user and group data')}}</label> + </div> + <div class="form-check"> + <input class="form-check-input" type="checkbox" id="client-perm-checkpassword" name="perm_checkpassword" value="1" aria-label="enabled" {{ 'checked' if client.perm_checkpassword }}> + <label class="form-check-label" for="client-perm-checkpassword"><b>checkpassword</b>: {{_('Verify user passwords')}}</label> + </div> + <div class="form-check"> + <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> + + </form> +</div> +{% endblock %} diff --git a/uffd/service/templates/service/index.html b/uffd/service/templates/service/index.html new file mode 100644 index 0000000000000000000000000000000000000000..97e569fd44a94496b4849f977045b7903436bae6 --- /dev/null +++ b/uffd/service/templates/service/index.html @@ -0,0 +1,31 @@ +{% extends 'base.html' %} + +{% block body %} +<div class="row"> + <div class="col"> + <p class="text-right"> + <a class="btn btn-primary" href="{{ url_for('service.show') }}"> + <i class="fa fa-plus" aria-hidden="true"></i> {{_("New")}} + </a> + </p> + <table class="table table-striped table-sm"> + <thead> + <tr> + <th scope="col">{{ _('Name') }}</th> + </tr> + </thead> + <tbody> + {% for service in services|sort(attribute="name") %} + <tr> + <td> + <a href="{{ url_for("service.show", id=service.id) }}"> + {{ service.name }} + </a> + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> +</div> +{% endblock %} diff --git a/uffd/service/templates/service/oauth2.html b/uffd/service/templates/service/oauth2.html new file mode 100644 index 0000000000000000000000000000000000000000..613fb82122267c6a1a4fd5d56a4580d7f9b05c87 --- /dev/null +++ b/uffd/service/templates/service/oauth2.html @@ -0,0 +1,55 @@ +{% extends 'base.html' %} + +{% block body %} +<div class="row"> + <form action="{{ url_for('service.oauth2_submit', service_id=service.id, db_id=client.db_id) }}" method="POST" class="form col-12 px-0"> + + <div class="form-group col"> + <p class="text-right"> + <a href="{{ url_for('service.show', id=service.id) }}" class="btn btn-secondary">{{ _('Cancel') }}</a> + {% if client.db_id %} + <a class="btn btn-danger" href="{{ url_for('service.oauth2_delete', service_id=service.id, db_id=client.db_id) }}" onClick='return confirm({{_("Are you sure?")|tojson}});'> + <i class="fa fa-trash" aria-hidden="true"></i> {{_('Delete')}} + </a> + {% endif %} + <button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> {{ _('Save') }}</button> + </p> + </div> + + <div class="form-group col"> + <label for="client-client-id">{{ _('Client ID') }}</label> + <input type="text" class="form-control" id="client-client-id" name="client_id" value="{{ client.client_id or '' }}" required> + </div> + + <div class="form-group col"> + <label for="client-client-secret">{{ _('Client Secret') }}</label> + {% if client.db_id %} + <input type="password" class="form-control" id="client-client-secret" name="client_secret" placeholder="●●●●●●●●"> + {% else %} + <input type="password" class="form-control" id="client-client-secret" name="client_secret" required> + {% endif %} + </div> + + <div class="form-group col"> + <label for="client-redirect-uris">{{ _('Redirect URIs') }}</label> + <textarea rows="3" class="form-control" id="client-redirect-uris" name="redirect_uris">{{ client.redirect_uris|join('\n') }}</textarea> + <small class="form-text text-muted"> + {{ _('One URI per line') }} + </small> + </div> + + <div class="form-group col"> + <label for="client-logout-uris">{{ _('Logout URIs') }}</label> + <textarea rows="3" class="form-control" id="client-logout-uris" name="logout_uris" placeholder="GET https://example.com/logout"> +{%- for logout_uri in client.logout_uris %} +{{ logout_uri.method }} {{ logout_uri.uri }} +{%- endfor %} +</textarea> + <small class="form-text text-muted"> + {{ _('One URI per line, prefixed with space-separated method (GET/POST)') }} + </small> + </div> + + </form> +</div> +{% endblock %} diff --git a/uffd/services/templates/services/overview.html b/uffd/service/templates/service/overview.html similarity index 93% rename from uffd/services/templates/services/overview.html rename to uffd/service/templates/service/overview.html index 0ccdb6f2b27d5690080899125f86ae02541b42a3..88b16c128d363a62fb6f778c192d535d5c34018b 100644 --- a/uffd/services/templates/services/overview.html +++ b/uffd/service/templates/service/overview.html @@ -59,6 +59,12 @@ </div> {% endmacro %} +{% if request.user and request.user.is_in_group(config['ACL_ADMIN_GROUP']) %} +<div class="text-right"> + <a href="{{ url_for('service.index') }}" class="btn btn-primary">{{ _('Manage OAuth2 and API clients') }}</a> +</div> +{% endif %} + <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 mt-2"> {% for service in services if service.has_access %} {{ service_card(service) }} diff --git a/uffd/service/templates/service/show.html b/uffd/service/templates/service/show.html new file mode 100644 index 0000000000000000000000000000000000000000..0bb892c258ea6c6ca36fa066bef3e34208dca675 --- /dev/null +++ b/uffd/service/templates/service/show.html @@ -0,0 +1,101 @@ +{% extends 'base.html' %} + +{% block body %} + +<div class="row"> + + <form action="{{ url_for('service.edit_submit', id=service.id) }}" method="POST" class="form col-12 px-0"> + <div class="form-group col"> + <p class="text-right"> + <a href="{{ url_for('service.index') }}" class="btn btn-secondary">{{ _('Cancel') }}</a> + {% if service.id %} + <a class="btn btn-danger" href="{{ url_for('service.delete', id=service.id) }}" onClick='return confirm({{_("Are you sure?")|tojson}});'> + <i class="fa fa-trash" aria-hidden="true"></i> {{_('Delete')}} + </a> + {% endif %} + <button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> {{ _('Save') }}</button> + </p> + </div> + <div class="form-group col"> + <label for="service-name">{{ _('Name') }}</label> + <input type="text" class="form-control" id="service-name" name="name" value="{{ service.name or '' }}" required> + </div> + <div class="form-group col"> + <label for="moderator-group">{{ _('Access Restriction') }}</label> + <select class="form-control" id="access-group" name="access-group"> + <option value="" class="text-muted">{{ _('No user has access') }}</option> + <option value="all" class="text-muted" {{ 'selected' if not service.limit_access }}>{{ _('All users have access (legacy)') }}</option> + {% for group in all_groups %} + <option value="{{ group.id }}" {{ 'selected' if group == service.access_group and service.limit_access }}>{{ _('Members of group "%(group_name)s" have access', group_name=group.name) }}</option> + {% endfor %} + </select> + </div> + </form> + + {% if service.id %} + <div class="col-12"> + <hr> + <h5>OAuth2 Clients</h5> + <p class="text-right"> + <a class="btn btn-primary" href="{{ url_for('service.oauth2_show', service_id=service.id) }}"> + <i class="fa fa-plus" aria-hidden="true"></i> {{_("New")}} + </a> + </p> + <table class="table table-striped table-sm"> + <thead> + <tr> + <th scope="col">{{ _('Client ID') }}</th> + </tr> + </thead> + <tbody> + {% for client in service.oauth2_clients|sort(attribute='client_id') %} + <tr> + <td> + <a href="{{ url_for("service.oauth2_show", service_id=service.id, db_id=client.db_id) }}"> + {{ client.client_id }} + </a> + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + + <div class="col-12"> + <hr> + <h5>API Clients</h5> + <p class="text-right"> + <a class="btn btn-primary" href="{{ url_for('service.api_show', service_id=service.id) }}"> + <i class="fa fa-plus" aria-hidden="true"></i> {{_("New")}} + </a> + </p> + <table class="table table-striped table-sm"> + <thead> + <tr> + <th scope="col">{{ _('Name') }}</th> + <th scope="col">{{ _('Permissions') }}</th> + </tr> + </thead> + <tbody> + {% for client in service.api_clients|sort(attribute='auth_username') %} + <tr> + <td> + <a href="{{ url_for("service.api_show", service_id=service.id, id=client.id) }}"> + {{ client.auth_username }} + </a> + </td> + <td> + {% for perm in ['users', 'checkpassword', 'mail_aliases'] if client.has_permission(perm) %} + {{ perm }}{{ ',' if not loop.last }} + {% endfor %} + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + {% endif %} + +</div> + +{% endblock %} diff --git a/uffd/service/views.py b/uffd/service/views.py new file mode 100644 index 0000000000000000000000000000000000000000..1f5623cae17965d3845fe519280ce3c51d22d73f --- /dev/null +++ b/uffd/service/views.py @@ -0,0 +1,161 @@ +from flask import Blueprint, render_template, request, url_for, redirect, current_app, abort +from flask_babel import lazy_gettext + +from uffd.navbar import register_navbar +from uffd.csrf import csrf_protect +from uffd.session import login_required +from uffd.service.models import Service, get_services +from uffd.user.models import Group +from uffd.oauth2.models import OAuth2Client, OAuth2LogoutURI +from uffd.api.models import APIClient +from uffd.database import db + +bp = Blueprint('service', __name__, template_folder='templates') + +def service_admin_acl(): + return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP']) + +def service_overview_acl(): + return len(get_services(request.user)) > 0 or service_admin_acl() + +@bp.route('/services/') +@register_navbar(lazy_gettext('Services'), icon='sitemap', blueprint=bp, visible=service_overview_acl) +def overview(): + services = get_services(request.user) + if not service_overview_acl(): + abort(404) + banner = current_app.config.get('SERVICES_BANNER') + # Set the banner to None if it is not public and no user is logged in + if not (current_app.config['SERVICES_BANNER_PUBLIC'] or request.user): + banner = None + return render_template('service/overview.html', user=request.user, services=services, banner=banner) + +@bp.route('/service/admin') +@login_required(service_admin_acl) +def index(): + return render_template('service/index.html', services=Service.query.all()) + +@bp.route('/service/new') +@bp.route('/service/<int:id>') +@login_required(service_admin_acl) +def show(id=None): + service = Service() if id is None else Service.query.get_or_404(id) + all_groups = Group.query.all() + return render_template('service/show.html', service=service, all_groups=all_groups) + +@bp.route('/service/new', methods=['POST']) +@bp.route('/service/<int:id>', methods=['POST']) +@csrf_protect(blueprint=bp) +@login_required(service_admin_acl) +def edit_submit(id=None): + if id is None: + service = Service() + db.session.add(service) + else: + service = Service.query.get_or_404(id) + service.name = request.form['name'] + if not request.form['access-group']: + service.limit_access = True + service.access_group = None + elif request.form['access-group'] == 'all': + service.limit_access = False + service.access_group = None + else: + service.limit_access = True + service.access_group = Group.query.get(request.form['access-group']) + db.session.commit() + return redirect(url_for('service.show', id=service.id)) + +@bp.route('/service/<int:id>/delete') +@csrf_protect(blueprint=bp) +@login_required(service_admin_acl) +def delete(id): + service = Service.query.get_or_404(id) + db.session.delete(service) + db.session.commit() + return redirect(url_for('service.index')) + +@bp.route('/service/<int:service_id>/oauth2/new') +@bp.route('/service/<int:service_id>/oauth2/<int:db_id>') +@login_required(service_admin_acl) +def oauth2_show(service_id, db_id=None): + service = Service.query.get_or_404(service_id) + client = OAuth2Client() if db_id is None else OAuth2Client.query.filter_by(service_id=service_id, db_id=db_id).first_or_404() + return render_template('service/oauth2.html', service=service, client=client) + +@bp.route('/service/<int:service_id>/oauth2/new', methods=['POST']) +@bp.route('/service/<int:service_id>/oauth2/<int:db_id>', methods=['POST']) +@csrf_protect(blueprint=bp) +@login_required(service_admin_acl) +def oauth2_submit(service_id, db_id=None): + service = Service.query.get_or_404(service_id) + if db_id is None: + client = OAuth2Client(service=service) + db.session.add(client) + else: + client = OAuth2Client.query.filter_by(service_id=service_id, db_id=db_id).first_or_404() + client.client_id = request.form['client_id'] + if request.form['client_secret']: + client.client_secret = request.form['client_secret'] + if not client.client_secret: + abort(400) + client.redirect_uris = request.form['redirect_uris'].strip().split('\n') + client.logout_uris = [] + for line in request.form['logout_uris'].split('\n'): + line = line.strip() + if not line: + continue + method, uri = line.split(' ', 2) + client.logout_uris.append(OAuth2LogoutURI(method=method, uri=uri)) + db.session.commit() + return redirect(url_for('service.show', id=service.id)) + +@bp.route('/service/<int:service_id>/oauth2/<int:db_id>/delete') +@csrf_protect(blueprint=bp) +@login_required(service_admin_acl) +def oauth2_delete(service_id, db_id=None): + service = Service.query.get_or_404(service_id) + client = OAuth2Client.query.filter_by(service_id=service_id, db_id=db_id).first_or_404() + db.session.delete(client) + db.session.commit() + return redirect(url_for('service.show', id=service.id)) + +@bp.route('/service/<int:service_id>/api/new') +@bp.route('/service/<int:service_id>/api/<int:id>') +@login_required(service_admin_acl) +def api_show(service_id, id=None): + service = Service.query.get_or_404(service_id) + client = APIClient() if id is None else APIClient.query.filter_by(service_id=service_id, id=id).first_or_404() + return render_template('service/api.html', service=service, client=client) + +@bp.route('/service/<int:service_id>/api/new', methods=['POST']) +@bp.route('/service/<int:service_id>/api/<int:id>', methods=['POST']) +@csrf_protect(blueprint=bp) +@login_required(service_admin_acl) +def api_submit(service_id, id=None): + service = Service.query.get_or_404(service_id) + if id is None: + client = APIClient(service=service) + db.session.add(client) + else: + client = APIClient.query.filter_by(service_id=service_id, id=id).first_or_404() + client.auth_username = request.form['auth_username'] + if request.form['auth_password']: + client.auth_password = request.form['auth_password'] + if not client.auth_password: + abort(400) + 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' + db.session.commit() + return redirect(url_for('service.show', id=service.id)) + +@bp.route('/service/<int:service_id>/api/<int:id>/delete') +@csrf_protect(blueprint=bp) +@login_required(service_admin_acl) +def api_delete(service_id, id=None): + service = Service.query.get_or_404(service_id) + client = APIClient.query.filter_by(service_id=service_id, id=id).first_or_404() + db.session.delete(client) + 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 fae15547dea316a68d4e4a766cc5cbe185c27655..a5e0810dc913757f660c8c17a94cff39d7f87cae 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 aac46a197d92b30b2b7f1834a1f40315ff96e8a4..91aa82472db8a8e69afeaaa2a26101cffac18074 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-02-15 23:23+0100\n" +"POT-Creation-Date: 2022-02-18 04:41+0100\n" "PO-Revision-Date: 2021-05-25 21:18+0200\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language: de\n" @@ -97,6 +97,9 @@ 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/user/templates/group/list.html:8 uffd/user/templates/user/list.html:8 msgid "New" msgstr "Neu" @@ -110,6 +113,8 @@ msgid "Created by" msgstr "Erstellt durch" #: uffd/invite/templates/invite/list.html:14 +#: uffd/service/templates/service/api.html:34 +#: uffd/service/templates/service/show.html:76 msgid "Permissions" msgstr "Berechtigungen" @@ -269,6 +274,9 @@ msgstr "Enthaltene Rollen" #: uffd/rolemod/templates/rolemod/list.html:9 #: uffd/rolemod/templates/rolemod/show.html:44 #: 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/user/templates/group/list.html:15 #: uffd/user/templates/group/show.html:26 #: uffd/user/templates/user/show.html:106 @@ -296,6 +304,9 @@ msgstr "Link erstellen" #: uffd/mail/templates/mail/show.html:28 uffd/mfa/templates/mfa/auth.html:33 #: uffd/role/templates/role/show.html:14 #: uffd/rolemod/templates/rolemod/show.html:9 +#: uffd/service/templates/service/api.html:9 +#: uffd/service/templates/service/oauth2.html:9 +#: uffd/service/templates/service/show.html:10 #: uffd/session/templates/session/deviceauth.html:39 #: uffd/session/templates/session/deviceauth.html:49 #: uffd/session/templates/session/devicelogin.html:29 @@ -354,16 +365,16 @@ msgstr "Anmelden und die Rollen zu deinem Account hinzufügen" msgid "Forwardings" msgstr "Weiterleitungen" -#: uffd/mail/views.py:46 +#: uffd/mail/views.py:47 #, python-format msgid "Invalid receive address: %(mail_address)s" msgstr "Ungültige Empfangsadresse: %(mail_address)s" -#: uffd/mail/views.py:50 +#: uffd/mail/views.py:51 msgid "Mail mapping updated." msgstr "Mailweiterleitung geändert." -#: uffd/mail/views.py:59 +#: uffd/mail/views.py:60 msgid "Deleted mail mapping." msgstr "Mailweiterleitung gelöscht." @@ -389,6 +400,9 @@ msgstr "Eine Adresse pro Zeile" #: uffd/mail/templates/mail/show.html:27 uffd/role/templates/role/show.html:13 #: uffd/rolemod/templates/rolemod/show.html:8 +#: uffd/service/templates/service/api.html:15 +#: uffd/service/templates/service/oauth2.html:15 +#: uffd/service/templates/service/show.html:16 #: uffd/user/templates/group/show.html:8 uffd/user/templates/user/show.html:7 msgid "Save" msgstr "Speichern" @@ -396,6 +410,9 @@ msgstr "Speichern" #: uffd/mail/templates/mail/show.html:30 uffd/mail/templates/mail/show.html:32 #: uffd/mfa/templates/mfa/setup.html:117 uffd/mfa/templates/mfa/setup.html:179 #: uffd/role/templates/role/show.html:21 uffd/role/templates/role/show.html:24 +#: uffd/service/templates/service/api.html:12 +#: uffd/service/templates/service/oauth2.html:12 +#: uffd/service/templates/service/show.html:13 #: uffd/user/templates/group/show.html:11 #: uffd/user/templates/group/show.html:13 uffd/user/templates/user/show.html:10 #: uffd/user/templates/user/show.html:12 @@ -721,10 +738,6 @@ msgstr "Sekunden" msgid "Verify and complete setup" msgstr "Verifiziere und beende das Setup" -#: uffd/oauth2/models.py:33 -msgid "You need to login to access this service" -msgstr "Du musst dich anmelden, um auf diesen Dienst zugreifen zu können" - #: uffd/oauth2/views.py:169 uffd/selfservice/views.py:71 #: uffd/session/views.py:73 #, python-format @@ -743,6 +756,10 @@ msgstr "Geräte-Login ist gerade nicht verfügbar. Versuche es später nochmal!" msgid "Device login failed" msgstr "Gerätelogin fehlgeschlagen" +#: uffd/oauth2/views.py:196 +msgid "You need to login to access this service" +msgstr "Du musst dich anmelden, um auf diesen Dienst zugreifen zu können" + #: uffd/oauth2/views.py:203 #, python-format msgid "" @@ -830,6 +847,9 @@ msgstr "Als Default setzen" #: uffd/role/templates/role/show.html:19 uffd/role/templates/role/show.html:21 #: uffd/selfservice/templates/selfservice/self.html:112 +#: uffd/service/templates/service/api.html:11 +#: uffd/service/templates/service/oauth2.html:11 +#: uffd/service/templates/service/show.html:12 #: uffd/user/templates/group/show.html:11 uffd/user/templates/user/show.html:10 #: uffd/user/templates/user/show.html:94 msgid "Are you sure?" @@ -1175,11 +1195,58 @@ msgstr "Passwort zurücksetzen" msgid "Set password" msgstr "Passwort setzen" -#: uffd/services/views.py:83 +#: uffd/service/views.py:22 msgid "Services" msgstr "Dienste" -#: uffd/services/templates/services/overview.html:8 +#: uffd/service/templates/service/api.html:20 +msgid "Authentication Username" +msgstr "Authentifikations-Name" + +#: uffd/service/templates/service/api.html:25 +msgid "Authentication Password" +msgstr "Authentifikations-Passwort" + +#: uffd/service/templates/service/api.html:37 +msgid "Access user and group data" +msgstr "Zugriff auf Account- und Gruppen-Daten" + +#: uffd/service/templates/service/api.html:41 +msgid "Verify user passwords" +msgstr "Passwörter von Nutzeraccounts verifizieren" + +#: uffd/service/templates/service/api.html:45 +msgid "Access mail aliases" +msgstr "Zugriff auf Mail-Weiterleitungen" + +#: uffd/service/templates/service/oauth2.html:20 +#: uffd/service/templates/service/show.html:47 +msgid "Client ID" +msgstr "Client-ID" + +#: uffd/service/templates/service/oauth2.html:25 +msgid "Client Secret" +msgstr "Client-Secret" + +#: uffd/service/templates/service/oauth2.html:34 +msgid "Redirect URIs" +msgstr "Redirect-URIs" + +#: uffd/service/templates/service/oauth2.html:37 +msgid "One URI per line" +msgstr "Eine URI pro Zeile" + +#: uffd/service/templates/service/oauth2.html:42 +msgid "Logout URIs" +msgstr "Abmelde-URIs" + +#: uffd/service/templates/service/oauth2.html:49 +msgid "One URI per line, prefixed with space-separated method (GET/POST)" +msgstr "" +"Eine URI pro Zeile, vorangestellt die mit Leerzeichen getrennte HTTP-" +"Methode (GET/POST)" + +#: uffd/service/templates/service/overview.html:8 msgid "" "Some services may not be publicly listed! Log in to see all services you " "have access to." @@ -1187,15 +1254,36 @@ msgstr "" "Einige Dienste sind eventuell nicht öffentlich aufgelistet! Melde dich an" " um alle Dienste zu sehen, auf die du Zugriff hast." -#: uffd/services/templates/services/overview.html:44 +#: uffd/service/templates/service/overview.html:44 msgid "No access" msgstr "Kein Zugriff" -#: uffd/services/templates/services/overview.html:78 +#: uffd/service/templates/service/overview.html:64 +msgid "Manage OAuth2 and API clients" +msgstr "OAuth2- und API-Clients verwalten" + +#: uffd/service/templates/service/overview.html:84 #: uffd/user/templates/user/list.html:58 uffd/user/templates/user/list.html:79 msgid "Close" msgstr "Schließen" +#: uffd/service/templates/service/show.html:24 +msgid "Access Restriction" +msgstr "Zugriffsbeschränkungen" + +#: uffd/service/templates/service/show.html:26 +msgid "No user has access" +msgstr "Kein Account hat Zugriff" + +#: uffd/service/templates/service/show.html:27 +msgid "All users have access (legacy)" +msgstr "Alle Account haben Zugriff (veraltet)" + +#: uffd/service/templates/service/show.html:29 +#, python-format +msgid "Members of group \"%(group_name)s\" have access" +msgstr "Mitglieder der Gruppe \"%(group_name)s\" haben Zugriff" + #: uffd/session/views.py:71 #, python-format msgid "" @@ -1445,7 +1533,7 @@ msgstr "Ändern" msgid "About uffd" msgstr "Über uffd" -#: uffd/user/models.py:48 +#: uffd/user/models.py:31 #, python-format msgid "" "At least %(minlen)d and at most %(maxlen)d characters. Only letters, "