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