From fa67bde07b4eb35708cb49a92e57901f9c7f133d Mon Sep 17 00:00:00 2001 From: Julian Rother <julian@cccv.de> Date: Thu, 24 Feb 2022 01:35:04 +0100 Subject: [PATCH] Migrate OAuth2 and API clients to database Also adds a shallow Service model that coexists with the config-defined services to group multiple OAuth2 and API clients together. Clients defined in the config with OAUTH2_CLIENTS and API_CLIENTS_2 are imported by the database migrations. Removes support for complex values for the OAuth2 client group_required option. Only simple group names are supported, not (nested) lists of groups previously interpreted as AND/OR conjunctions. Also removes support for the login_message parameter of OAuth2 clients. --- README.md | 11 + check_migrations.py | 12 +- tests/test_api.py | 44 +-- tests/test_oauth2.py | 54 ++- tests/test_services.py | 10 +- tests/test_session.py | 9 +- tests/utils.py | 4 + uffd/__init__.py | 6 +- uffd/api/models.py | 26 ++ uffd/api/views.py | 34 +- uffd/default_config.cfg | 16 - ...ac9db_move_api_and_oauth2_clients_to_db.py | 309 ++++++++++++++++++ uffd/oauth2/models.py | 97 +++--- uffd/oauth2/views.py | 41 +-- .../templates/selfservice/self.html | 2 +- uffd/{services => service}/__init__.py | 0 uffd/{services/views.py => service/models.py} | 57 ++-- uffd/service/templates/service/api.html | 51 +++ uffd/service/templates/service/index.html | 31 ++ uffd/service/templates/service/oauth2.html | 55 ++++ .../templates/service}/overview.html | 6 + uffd/service/templates/service/show.html | 101 ++++++ uffd/service/views.py | 161 +++++++++ uffd/translations/de/LC_MESSAGES/messages.mo | Bin 32125 -> 33274 bytes uffd/translations/de/LC_MESSAGES/messages.po | 114 ++++++- 25 files changed, 1037 insertions(+), 214 deletions(-) create mode 100644 uffd/api/models.py create mode 100644 uffd/migrations/versions/b9d3f7dac9db_move_api_and_oauth2_clients_to_db.py rename uffd/{services => service}/__init__.py (100%) rename uffd/{services/views.py => service/models.py} (64%) create mode 100644 uffd/service/templates/service/api.html create mode 100644 uffd/service/templates/service/index.html create mode 100644 uffd/service/templates/service/oauth2.html rename uffd/{services/templates/services => service/templates/service}/overview.html (93%) create mode 100644 uffd/service/templates/service/show.html create mode 100644 uffd/service/views.py diff --git a/README.md b/README.md index 8bca22a3..27b4c843 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 9ccf98d6..70f5d257 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 b01a6ff3..81f02480 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 47a97f2a..d3e68dfe 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 89b83fb2..269d1455 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 711c76b9..173adfd1 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 0fd6fbb8..b043f3af 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 03ba6d42..8831fbdb 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 00000000..391a0aed --- /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 16446fe7..208b7ff7 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 718e4933..6546b002 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 00000000..0a2c47b7 --- /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 bae0f7ce..4621f114 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 b52e3600..cfb089b3 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 d505d2bb..0765b3c3 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 3423eba4..7d3501e1 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 00000000..9e0f7a8b --- /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 00000000..97e569fd --- /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 00000000..613fb821 --- /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 0ccdb6f2..88b16c12 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 00000000..0bb892c2 --- /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 00000000..1f5623ca --- /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 GIT binary patch delta 6314 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 HffO$QGrQP; delta 5205 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 diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po index aac46a19..91aa8247 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, " -- GitLab