diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index c301af2dd0cbd39beda03144d01dc0bf31cd5144..e7476c4f92d622a9380e449382874205a7db9b4e 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,4 +1,4 @@
-image: registry.git.cccv.de/infra/uffd/docker-images/buster
+image: registry.git.cccv.de/uffd/docker-images/buster
 
 variables:
   DEBIAN_FRONTEND: noninteractive 
diff --git a/README.md b/README.md
index e6661c0c606f970aa6ea31c139883c09e05de637..e7f8c23c15f2aedbe077991d596f07f9f47bc05c 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,11 @@ You can also use virtualenv with the supplied `requirements.txt`.
 
 ## development
 
-Before running uffd, you need to create the database with `flask db upgrade`.
+Clone this repository with the `--recurse-submodules` flag to retrieve submodule dependencies.
+
+Before running uffd, you need to create the database with `flask db upgrade`. The database is placed in
+`instance/uffd.sqlit3`.
+
 Then use `flask run` to start the application:
 
 ```
@@ -61,6 +65,13 @@ hook-pre-app = exec:FLASK_APP=uffd flask db upgrade
 
 tabs.
 
+## Config
+
+Uffd reads its default config from `uffd/default_config.cfg`.
+You can overwrite config variables by creating a config file in the `instance` folder.
+The file must be named `conifg.cfg` (Python syntax), `config.json` or `config.yml`/`config.yaml`.
+You can also set a custom file name with the environment variable `CONFIG_FILENAME`.
+
 ## Bind with service account or as user?
 
 Uffd can use a dedicated service account for LDAP operations by setting `LDAP_SERVICE_BIND_DN`.
@@ -70,7 +81,6 @@ Or set `LDAP_SERVICE_USER_BIND` to use the credentials of the currently logged i
 If you choose to run with user credentials, some features are not available, like password resets
 or self signup, since in both cases, no user credentials can exist. 
 
-
 ## OAuth2 Single-Sign-On Provider
 
 Other services can use uffd as an OAuth2.0-based authentication provider.
diff --git a/migrations/versions/a594d3b3e05b_added_role_locked.py b/migrations/versions/a594d3b3e05b_added_role_locked.py
new file mode 100644
index 0000000000000000000000000000000000000000..5ca1b89a97010bdfb11c51f35410d9281187e3a8
--- /dev/null
+++ b/migrations/versions/a594d3b3e05b_added_role_locked.py
@@ -0,0 +1,23 @@
+"""added role.locked
+
+Revision ID: a594d3b3e05b
+Revises: 5cab70e95bf8
+Create Date: 2021-06-14 00:32:47.792794
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = 'a594d3b3e05b'
+down_revision = '5cab70e95bf8'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+	with op.batch_alter_table('role', schema=None) as batch_op:
+		batch_op.add_column(sa.Column('locked', sa.Boolean(name=op.f('ck_role_locked')), nullable=False, default=False))
+
+def downgrade():
+	with op.batch_alter_table('role', schema=None) as batch_op:
+		batch_op.drop_column('locked')
diff --git a/tests/test_invite.py b/tests/test_invite.py
index d3691d05c65cbdb3ce68c93d94a668f79fb8b548..c21a15c16d370febac1744212ae77216fb7ac108 100644
--- a/tests/test_invite.py
+++ b/tests/test_invite.py
@@ -12,7 +12,7 @@ from uffd import create_app, db
 from uffd.invite.models import Invite, InviteGrant, InviteSignup
 from uffd.user.models import User, Group
 from uffd.role.models import Role
-from uffd.session.views import get_current_user, is_valid_session, login_get_user
+from uffd.session.views import login_get_user
 
 from utils import dump, UffdTestCase, db_flush
 
diff --git a/tests/test_mfa.py b/tests/test_mfa.py
index 4ea98102e0d48f6980dc40c8140e0cd5f8f46fd3..dc0c657cb3c7138b409474e6f3c20cd3a53d9a48 100644
--- a/tests/test_mfa.py
+++ b/tests/test_mfa.py
@@ -2,13 +2,12 @@ import unittest
 import datetime
 import time
 
-from flask import url_for, session
+from flask import url_for, session, request
 
 # These imports are required, because otherwise we get circular imports?!
 from uffd import ldap, user
 
 from uffd.user.models import User
-from uffd.session.views import get_current_user, is_valid_session
 from uffd.mfa.models import MFAMethod, MFAType, RecoveryCodeMethod, TOTPMethod, WebauthnMethod, _hotp
 from uffd import create_app, db
 
@@ -176,21 +175,21 @@ class TestMfaViews(UffdTestCase):
 		r = self.client.post(path=url_for('mfa.disable_confirm'), follow_redirects=True)
 		dump('mfa_disable_submit', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertEqual(len(MFAMethod.query.filter_by(dn=get_current_user().dn).all()), 0)
+		self.assertEqual(len(MFAMethod.query.filter_by(dn=request.user.dn).all()), 0)
 		self.assertEqual(len(MFAMethod.query.filter_by(dn=get_admin().dn).all()), admin_methods)
 
 	def test_disable_recovery_only(self):
 		self.login()
 		self.add_recovery_codes()
 		admin_methods = len(MFAMethod.query.filter_by(dn=get_admin().dn).all())
-		self.assertNotEqual(len(MFAMethod.query.filter_by(dn=get_current_user().dn).all()), 0)
+		self.assertNotEqual(len(MFAMethod.query.filter_by(dn=request.user.dn).all()), 0)
 		r = self.client.get(path=url_for('mfa.disable'), follow_redirects=True)
 		dump('mfa_disable_recovery_only', r)
 		self.assertEqual(r.status_code, 200)
 		r = self.client.post(path=url_for('mfa.disable_confirm'), follow_redirects=True)
 		dump('mfa_disable_recovery_only_submit', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertEqual(len(MFAMethod.query.filter_by(dn=get_current_user().dn).all()), 0)
+		self.assertEqual(len(MFAMethod.query.filter_by(dn=request.user.dn).all()), 0)
 		self.assertEqual(len(MFAMethod.query.filter_by(dn=get_admin().dn).all()), admin_methods)
 
 	def test_admin_disable(self):
@@ -202,7 +201,7 @@ class TestMfaViews(UffdTestCase):
 		self.add_totp()
 		self.client.post(path=url_for('session.login'),
 			data={'loginname': 'testadmin', 'password': 'adminpassword'}, follow_redirects=True)
-		self.assertTrue(is_valid_session())
+		self.assertIsNotNone(request.user)
 		admin_methods = len(MFAMethod.query.filter_by(dn=get_admin().dn).all())
 		r = self.client.get(path=url_for('mfa.admin_disable', uid=get_user().uid), follow_redirects=True)
 		dump('mfa_admin_disable', r)
@@ -212,11 +211,11 @@ class TestMfaViews(UffdTestCase):
 
 	def test_setup_recovery(self):
 		self.login()
-		self.assertEqual(len(RecoveryCodeMethod.query.filter_by(dn=get_current_user().dn).all()), 0)
+		self.assertEqual(len(RecoveryCodeMethod.query.filter_by(dn=request.user.dn).all()), 0)
 		r = self.client.post(path=url_for('mfa.setup_recovery'), follow_redirects=True)
 		dump('mfa_setup_recovery', r)
 		self.assertEqual(r.status_code, 200)
-		methods = RecoveryCodeMethod.query.filter_by(dn=get_current_user().dn).all()
+		methods = RecoveryCodeMethod.query.filter_by(dn=request.user.dn).all()
 		self.assertNotEqual(len(methods), 0)
 		r = self.client.post(path=url_for('mfa.setup_recovery'), follow_redirects=True)
 		dump('mfa_setup_recovery_reset', r)
@@ -241,62 +240,62 @@ class TestMfaViews(UffdTestCase):
 	def test_setup_totp_finish(self):
 		self.login()
 		self.add_recovery_codes()
-		self.assertEqual(len(TOTPMethod.query.filter_by(dn=get_current_user().dn).all()), 0)
+		self.assertEqual(len(TOTPMethod.query.filter_by(dn=request.user.dn).all()), 0)
 		r = self.client.get(path=url_for('mfa.setup_totp', name='My TOTP Authenticator'), follow_redirects=True)
-		method = TOTPMethod(get_current_user(), key=session.get('mfa_totp_key', ''))
+		method = TOTPMethod(request.user, key=session.get('mfa_totp_key', ''))
 		code = _hotp(int(time.time()/30), method.raw_key)
 		r = self.client.post(path=url_for('mfa.setup_totp_finish', name='My TOTP Authenticator'), data={'code': code}, follow_redirects=True)
 		dump('mfa_setup_totp_finish', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertEqual(len(TOTPMethod.query.filter_by(dn=get_current_user().dn).all()), 1)
+		self.assertEqual(len(TOTPMethod.query.filter_by(dn=request.user.dn).all()), 1)
 
 	def test_setup_totp_finish_without_recovery(self):
 		self.login()
-		self.assertEqual(len(TOTPMethod.query.filter_by(dn=get_current_user().dn).all()), 0)
+		self.assertEqual(len(TOTPMethod.query.filter_by(dn=request.user.dn).all()), 0)
 		r = self.client.get(path=url_for('mfa.setup_totp', name='My TOTP Authenticator'), follow_redirects=True)
-		method = TOTPMethod(get_current_user(), key=session.get('mfa_totp_key', ''))
+		method = TOTPMethod(request.user, key=session.get('mfa_totp_key', ''))
 		code = _hotp(int(time.time()/30), method.raw_key)
 		r = self.client.post(path=url_for('mfa.setup_totp_finish', name='My TOTP Authenticator'), data={'code': code}, follow_redirects=True)
 		dump('mfa_setup_totp_finish_without_recovery', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertEqual(len(TOTPMethod.query.filter_by(dn=get_current_user().dn).all()), 0)
+		self.assertEqual(len(TOTPMethod.query.filter_by(dn=request.user.dn).all()), 0)
 
 	def test_setup_totp_finish_wrong_code(self):
 		self.login()
 		self.add_recovery_codes()
-		self.assertEqual(len(TOTPMethod.query.filter_by(dn=get_current_user().dn).all()), 0)
+		self.assertEqual(len(TOTPMethod.query.filter_by(dn=request.user.dn).all()), 0)
 		r = self.client.get(path=url_for('mfa.setup_totp', name='My TOTP Authenticator'), follow_redirects=True)
-		method = TOTPMethod(get_current_user(), key=session.get('mfa_totp_key', ''))
+		method = TOTPMethod(request.user, key=session.get('mfa_totp_key', ''))
 		code = _hotp(int(time.time()/30), method.raw_key)
 		code = str(int(code[0])+1)[-1] + code[1:]
 		r = self.client.post(path=url_for('mfa.setup_totp_finish', name='My TOTP Authenticator'), data={'code': code}, follow_redirects=True)
 		dump('mfa_setup_totp_finish_wrong_code', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertEqual(len(TOTPMethod.query.filter_by(dn=get_current_user().dn).all()), 0)
+		self.assertEqual(len(TOTPMethod.query.filter_by(dn=request.user.dn).all()), 0)
 
 	def test_setup_totp_finish_empty_code(self):
 		self.login()
 		self.add_recovery_codes()
-		self.assertEqual(len(TOTPMethod.query.filter_by(dn=get_current_user().dn).all()), 0)
+		self.assertEqual(len(TOTPMethod.query.filter_by(dn=request.user.dn).all()), 0)
 		r = self.client.get(path=url_for('mfa.setup_totp', name='My TOTP Authenticator'), follow_redirects=True)
 		r = self.client.post(path=url_for('mfa.setup_totp_finish', name='My TOTP Authenticator'), data={'code': ''}, follow_redirects=True)
 		dump('mfa_setup_totp_finish_empty_code', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertEqual(len(TOTPMethod.query.filter_by(dn=get_current_user().dn).all()), 0)
+		self.assertEqual(len(TOTPMethod.query.filter_by(dn=request.user.dn).all()), 0)
 
 	def test_delete_totp(self):
 		self.login()
 		self.add_recovery_codes()
 		self.add_totp()
-		method = TOTPMethod(get_current_user(), name='test')
+		method = TOTPMethod(request.user, name='test')
 		db.session.add(method)
 		db.session.commit()
-		self.assertEqual(len(TOTPMethod.query.filter_by(dn=get_current_user().dn).all()), 2)
+		self.assertEqual(len(TOTPMethod.query.filter_by(dn=request.user.dn).all()), 2)
 		r = self.client.get(path=url_for('mfa.delete_totp', id=method.id), follow_redirects=True)
 		dump('mfa_delete_totp', r)
 		self.assertEqual(r.status_code, 200)
 		self.assertEqual(len(TOTPMethod.query.filter_by(id=method.id).all()), 0)
-		self.assertEqual(len(TOTPMethod.query.filter_by(dn=get_current_user().dn).all()), 1)
+		self.assertEqual(len(TOTPMethod.query.filter_by(dn=request.user.dn).all()), 1)
 
 	# TODO: webauthn setup tests
 
@@ -304,36 +303,36 @@ class TestMfaViews(UffdTestCase):
 		self.add_recovery_codes()
 		self.add_totp()
 		db.session.commit()
-		self.assertFalse(is_valid_session())
+		self.assertIsNone(request.user)
 		r = self.client.post(path=url_for('session.login'),
 			data={'loginname': 'testuser', 'password': 'userpassword'}, follow_redirects=True)
 		dump('mfa_auth_redirected', r)
 		self.assertEqual(r.status_code, 200)
 		self.assertIn(b'/mfa/auth', r.data)
-		self.assertFalse(is_valid_session())
+		self.assertIsNone(request.user)
 		r = self.client.get(path=url_for('mfa.auth'), follow_redirects=False)
 		dump('mfa_auth', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertFalse(is_valid_session())
+		self.assertIsNone(request.user)
 
 	def test_auth_disabled(self):
-		self.assertFalse(is_valid_session())
+		self.assertIsNone(request.user)
 		r = self.client.post(path=url_for('session.login'),
 			data={'loginname': 'testuser', 'password': 'userpassword'}, follow_redirects=False)
 		r = self.client.get(path=url_for('mfa.auth', ref='/redirecttarget'), follow_redirects=False)
 		self.assertEqual(r.status_code, 302)
 		self.assertTrue(r.location.endswith('/redirecttarget'))
-		self.assertTrue(is_valid_session())
+		self.assertIsNotNone(request.user)
 
 	def test_auth_recovery_only(self):
 		self.add_recovery_codes()
-		self.assertFalse(is_valid_session())
+		self.assertIsNone(request.user)
 		r = self.client.post(path=url_for('session.login'),
 			data={'loginname': 'testuser', 'password': 'userpassword'}, follow_redirects=False)
 		r = self.client.get(path=url_for('mfa.auth', ref='/redirecttarget'), follow_redirects=False)
 		self.assertEqual(r.status_code, 302)
 		self.assertTrue(r.location.endswith('/redirecttarget'))
-		self.assertTrue(is_valid_session())
+		self.assertIsNotNone(request.user)
 
 	def test_auth_recovery_code(self):
 		self.add_recovery_codes()
@@ -346,11 +345,11 @@ class TestMfaViews(UffdTestCase):
 		r = self.client.get(path=url_for('mfa.auth'), follow_redirects=False)
 		dump('mfa_auth_recovery_code', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertFalse(is_valid_session())
+		self.assertIsNone(request.user)
 		r = self.client.post(path=url_for('mfa.auth_finish', ref='/redirecttarget'), data={'code': method.code})
 		self.assertEqual(r.status_code, 302)
 		self.assertTrue(r.location.endswith('/redirecttarget'))
-		self.assertTrue(is_valid_session())
+		self.assertIsNotNone(request.user)
 		self.assertEqual(len(RecoveryCodeMethod.query.filter_by(id=method_id).all()), 0)
 
 	def test_auth_totp_code(self):
@@ -364,12 +363,12 @@ class TestMfaViews(UffdTestCase):
 		r = self.client.get(path=url_for('mfa.auth'), follow_redirects=False)
 		dump('mfa_auth_totp_code', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertFalse(is_valid_session())
+		self.assertIsNone(request.user)
 		code = _hotp(int(time.time()/30), raw_key)
 		r = self.client.post(path=url_for('mfa.auth_finish'), data={'code': code}, follow_redirects=True)
 		dump('mfa_auth_totp_code_submit', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertTrue(is_valid_session())
+		self.assertIsNotNone(request.user)
 
 	def test_auth_empty_code(self):
 		self.add_recovery_codes()
@@ -377,11 +376,11 @@ class TestMfaViews(UffdTestCase):
 		self.login()
 		r = self.client.get(path=url_for('mfa.auth'), follow_redirects=False)
 		self.assertEqual(r.status_code, 200)
-		self.assertFalse(is_valid_session())
+		self.assertIsNone(request.user)
 		r = self.client.post(path=url_for('mfa.auth_finish'), data={'code': ''}, follow_redirects=True)
 		dump('mfa_auth_empty_code', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertFalse(is_valid_session())
+		self.assertIsNone(request.user)
 
 	def test_auth_invalid_code(self):
 		self.add_recovery_codes()
@@ -393,13 +392,13 @@ class TestMfaViews(UffdTestCase):
 		self.login()
 		r = self.client.get(path=url_for('mfa.auth'), follow_redirects=False)
 		self.assertEqual(r.status_code, 200)
-		self.assertFalse(is_valid_session())
+		self.assertIsNone(request.user)
 		code = _hotp(int(time.time()/30), raw_key)
 		code = str(int(code[0])+1)[-1] + code[1:]
 		r = self.client.post(path=url_for('mfa.auth_finish'), data={'code': code}, follow_redirects=True)
 		dump('mfa_auth_invalid_code', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertFalse(is_valid_session())
+		self.assertIsNone(request.user)
 
 	def test_auth_ratelimit(self):
 		self.add_recovery_codes()
@@ -409,17 +408,17 @@ class TestMfaViews(UffdTestCase):
 		db.session.add(method)
 		db.session.commit()
 		self.login()
-		self.assertFalse(is_valid_session())
+		self.assertIsNone(request.user)
 		code = _hotp(int(time.time()/30), raw_key)
 		inv_code = str(int(code[0])+1)[-1] + code[1:]
 		for i in range(20):
 			r = self.client.post(path=url_for('mfa.auth_finish'), data={'code': inv_code}, follow_redirects=True)
 			self.assertEqual(r.status_code, 200)
-			self.assertFalse(is_valid_session())
+			self.assertIsNone(request.user)
 		r = self.client.post(path=url_for('mfa.auth_finish'), data={'code': code}, follow_redirects=True)
 		dump('mfa_auth_ratelimit', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertFalse(is_valid_session())
+		self.assertIsNone(request.user)
 
 	# TODO: webauthn auth tests
 
diff --git a/tests/test_oauth2.py b/tests/test_oauth2.py
index 1dc7e1c5ace260f37550e118d5bbb20c69dbffaf..b77a09925ed7702325c91c4b3e0a6ed694cb14e5 100644
--- a/tests/test_oauth2.py
+++ b/tests/test_oauth2.py
@@ -6,7 +6,6 @@ from flask import url_for
 # These imports are required, because otherwise we get circular imports?!
 from uffd import ldap, user
 
-from uffd.session.views import get_current_user
 from uffd.user.models import User
 from uffd.oauth2.models import OAuth2Client
 from uffd import create_app, db, ldap
diff --git a/tests/test_rolemod.py b/tests/test_rolemod.py
index f8598ab6d73de7a84e19823fa5e226471251784a..f1baba1a694f9044d324cbd9089d370afd602f6a 100644
--- a/tests/test_rolemod.py
+++ b/tests/test_rolemod.py
@@ -1,7 +1,6 @@
 from flask import url_for
 
 from uffd.user.models import User, Group
-from uffd.session import get_current_user
 from uffd.role.models import Role
 from uffd.database import db
 from uffd.ldap import ldap
diff --git a/tests/test_selfservice.py b/tests/test_selfservice.py
index 1d6ab94be099dc98da7a2927370fe38b3b3b1b39..6d5d96980406df9fd75fda7cf9aa41fe31ee36ec 100644
--- a/tests/test_selfservice.py
+++ b/tests/test_selfservice.py
@@ -1,12 +1,11 @@
 import datetime
 import unittest
 
-from flask import url_for
+from flask import url_for, request
 
 # These imports are required, because otherwise we get circular imports?!
 from uffd import ldap, user
 
-from uffd.session.views import get_current_user
 from uffd.selfservice.models import MailToken, PasswordToken
 from uffd.user.models import User
 from uffd import create_app, db
@@ -28,88 +27,88 @@ class TestSelfservice(UffdTestCase):
 		r = self.client.get(path=url_for('selfservice.index'))
 		dump('selfservice_index', r)
 		self.assertEqual(r.status_code, 200)
-		user = get_current_user()
+		user = request.user
 		self.assertIn(user.displayname.encode(), r.data)
 		self.assertIn(user.loginname.encode(), r.data)
 		self.assertIn(user.mail.encode(), r.data)
 
 	def test_update_displayname(self):
 		self.login()
-		user = get_current_user()
+		user = request.user
 		r = self.client.post(path=url_for('selfservice.update'),
 			data={'displayname': 'New Display Name', 'mail': user.mail, 'password': '', 'password1': ''},
 			follow_redirects=True)
 		dump('update_displayname', r)
 		self.assertEqual(r.status_code, 200)
-		_user = get_current_user()
+		_user = request.user
 		self.assertEqual(_user.displayname, 'New Display Name')
 
 	def test_update_displayname_invalid(self):
 		self.login()
-		user = get_current_user()
+		user = request.user
 		r = self.client.post(path=url_for('selfservice.update'),
 			data={'displayname': '', 'mail': user.mail, 'password': '', 'password1': ''},
 			follow_redirects=True)
 		dump('update_displayname_invalid', r)
 		self.assertEqual(r.status_code, 200)
-		_user = get_current_user()
+		_user = request.user
 		self.assertNotEqual(_user.displayname, '')
 
 	def test_update_mail(self):
 		self.login()
-		user = get_current_user()
+		user = request.user
 		r = self.client.post(path=url_for('selfservice.update'),
 			data={'displayname': user.displayname, 'mail': 'newemail@example.com', 'password': '', 'password1': ''},
 			follow_redirects=True)
 		dump('update_mail', r)
 		self.assertEqual(r.status_code, 200)
-		_user = get_current_user()
+		_user = request.user
 		self.assertNotEqual(_user.mail, 'newemail@example.com')
 		token = MailToken.query.filter(MailToken.loginname == user.loginname).first()
 		self.assertEqual(token.newmail, 'newemail@example.com')
 		self.assertIn(token.token, str(self.app.last_mail.get_content()))
 		r = self.client.get(path=url_for('selfservice.token_mail', token=token.token), follow_redirects=True)
 		self.assertEqual(r.status_code, 200)
-		_user = get_current_user()
+		_user = request.user
 		self.assertEqual(_user.mail, 'newemail@example.com')
 
 	def test_update_mail_sendfailure(self):
 		self.app.config['MAIL_SKIP_SEND'] = 'fail'
 		self.login()
-		user = get_current_user()
+		user = request.user
 		r = self.client.post(path=url_for('selfservice.update'),
 			data={'displayname': user.displayname, 'mail': 'newemail@example.com', 'password': '', 'password1': ''},
 			follow_redirects=True)
 		dump('update_mail_sendfailure', r)
 		self.assertEqual(r.status_code, 200)
-		_user = get_current_user()
+		_user = request.user
 		self.assertNotEqual(_user.mail, 'newemail@example.com')
 		# Maybe also check that there is no new token in the db
 
 	def test_token_mail_emptydb(self):
 		self.login()
-		user = get_current_user()
+		user = request.user
 		r = self.client.get(path=url_for('selfservice.token_mail', token='A'*128), follow_redirects=True)
 		dump('token_mail_emptydb', r)
 		self.assertEqual(r.status_code, 200)
-		_user = get_current_user()
+		_user = request.user
 		self.assertEqual(_user.mail, user.mail)
 
 	def test_token_mail_invalid(self):
 		self.login()
-		user = get_current_user()
+		user = request.user
 		db.session.add(MailToken(loginname=user.loginname, newmail='newusermail@example.com'))
 		db.session.commit()
 		r = self.client.get(path=url_for('selfservice.token_mail', token='A'*128), follow_redirects=True)
 		dump('token_mail_invalid', r)
 		self.assertEqual(r.status_code, 200)
-		_user = get_current_user()
+		_user = request.user
 		self.assertEqual(_user.mail, user.mail)
 
 	@unittest.skip('See #26')
 	def test_token_mail_wrong_user(self):
 		self.login()
-		user = get_current_user()
+		user = request.user
 		admin_user = User.query.get('uid=testadmin,ou=users,dc=example,dc=com')
 		db.session.add(MailToken(loginname=user.loginname, newmail='newusermail@example.com'))
 		admin_token = MailToken(loginname='testadmin', newmail='newadminmail@example.com')
@@ -118,14 +117,14 @@ class TestSelfservice(UffdTestCase):
 		r = self.client.get(path=url_for('selfservice.token_mail', token=admin_token.token), follow_redirects=True)
 		dump('token_mail_wrong_user', r)
 		self.assertEqual(r.status_code, 200)
-		_user = get_current_user()
+		_user = request.user
 		_admin_user = User.query.get('uid=testadmin,ou=users,dc=example,dc=com')
 		self.assertEqual(_user.mail, user.mail)
 		self.assertEqual(_admin_user.mail, admin_user.mail)
 
 	def test_token_mail_expired(self):
 		self.login()
-		user = get_current_user()
+		user = request.user
 		token = MailToken(loginname=user.loginname, newmail='newusermail@example.com',
 			created=(datetime.datetime.now() - datetime.timedelta(days=10)))
 		db.session.add(token)
@@ -133,7 +132,7 @@ class TestSelfservice(UffdTestCase):
 		r = self.client.get(path=url_for('selfservice.token_mail', token=token.token), follow_redirects=True)
 		dump('token_mail_expired', r)
 		self.assertEqual(r.status_code, 200)
-		_user = get_current_user()
+		_user = request.user
 		self.assertEqual(_user.mail, user.mail)
 		tokens = MailToken.query.filter(MailToken.loginname == user.loginname).all()
 		self.assertEqual(len(tokens), 0)
diff --git a/tests/test_session.py b/tests/test_session.py
index dae41ab5a06e4e37d61b4f322911b4e62dd8fd26..0882b08e16d895cc474e951488a312ba88c74e14 100644
--- a/tests/test_session.py
+++ b/tests/test_session.py
@@ -1,12 +1,12 @@
 import time
 import unittest
 
-from flask import url_for
+from flask import url_for, request
 
 # These imports are required, because otherwise we get circular imports?!
 from uffd import ldap, user
 
-from uffd.session.views import get_current_user, login_required, is_valid_session
+from uffd.session.views import login_required
 from uffd import create_app, db
 
 from utils import dump, UffdTestCase
@@ -32,24 +32,24 @@ class TestSession(UffdTestCase):
 
 	def setUp(self):
 		super().setUp()
-		self.assertFalse(is_valid_session())
+		self.assertIsNone(request.user)
 
 	def login(self):
 		self.client.post(path=url_for('session.login'),
 			data={'loginname': 'testuser', 'password': 'userpassword'}, follow_redirects=True)
-		self.assertTrue(is_valid_session())
+		self.assertIsNotNone(request.user)
 
 	def assertLogin(self):
-		self.assertTrue(is_valid_session())
+		self.assertIsNotNone(request.user)
 		self.assertEqual(self.client.get(path=url_for('test_login_required'),
 			follow_redirects=True).data, b'SUCCESS')
-		self.assertEqual(get_current_user().loginname, 'testuser')
+		self.assertEqual(request.user.loginname, 'testuser')
 
 	def assertLogout(self):
-		self.assertFalse(is_valid_session())
+		self.assertIsNone(request.user)
 		self.assertNotEqual(self.client.get(path=url_for('test_login_required'),
 			follow_redirects=True).data, b'SUCCESS')
-		self.assertEqual(get_current_user(), None)
+		self.assertEqual(request.user, None)
 
 	def test_login(self):
 		self.assertLogout()
@@ -131,7 +131,7 @@ class TestSession(UffdTestCase):
 			data={'loginname': 'testuser', 'password': 'userpassword'}, follow_redirects=True)
 		dump('login_ratelimit', r)
 		self.assertEqual(r.status_code, 200)
-		self.assertFalse(is_valid_session())
+		self.assertIsNone(request.user)
 
 class TestSessionOL(TestSession):
 	use_openldap = True
diff --git a/tests/test_signup.py b/tests/test_signup.py
index 501e8896db3f63cc05c80bae0be5270f1f5e6378..65007789c8eb149bb5688b85ca5f964804c9e3db 100644
--- a/tests/test_signup.py
+++ b/tests/test_signup.py
@@ -2,7 +2,7 @@ import unittest
 import datetime
 import time
 
-from flask import url_for, session
+from flask import url_for, session, request
 
 # These imports are required, because otherwise we get circular imports?!
 from uffd import user
@@ -11,7 +11,7 @@ from uffd.ldap import ldap
 from uffd import create_app, db
 from uffd.signup.models import Signup
 from uffd.user.models import User
-from uffd.session.views import get_current_user, is_valid_session, login_get_user
+from uffd.session.views import login_get_user
 
 from utils import dump, UffdTestCase, db_flush
 
@@ -345,8 +345,8 @@ class TestSignupViews(UffdTestCase):
 		self.assertEqual(signup.user.mail, 'test@example.com')
 		if self.use_openldap:
 			self.assertIsNotNone(login_get_user('newuser', 'notsecret'))
-		self.assertTrue(is_valid_session())
-		self.assertEqual(get_current_user().loginname, 'newuser')
+		self.assertIsNotNone(request.user)
+		self.assertEqual(request.user.loginname, 'newuser')
 
 	def test_confirm_loggedin(self):
 		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
@@ -354,16 +354,16 @@ class TestSignupViews(UffdTestCase):
 		self.client.post(path=url_for('session.login'),
 			data={'loginname': 'testuser', 'password': 'userpassword'}, follow_redirects=True)
 		self.assertFalse(signup.completed)
-		self.assertTrue(is_valid_session())
-		self.assertEqual(get_current_user().loginname, 'testuser')
+		self.assertIsNotNone(request.user)
+		self.assertEqual(request.user.loginname, 'testuser')
 		r = self.client.get(path=url_for('signup.signup_confirm', token=signup.token), follow_redirects=True)
 		self.assertEqual(r.status_code, 200)
 		r = self.client.post(path=url_for('signup.signup_confirm_submit', token=signup.token), follow_redirects=True, data={'password': 'notsecret'})
 		self.assertEqual(r.status_code, 200)
 		signup = refetch_signup(signup)
 		self.assertTrue(signup.completed)
-		self.assertTrue(is_valid_session())
-		self.assertEqual(get_current_user().loginname, 'newuser')
+		self.assertIsNotNone(request.user)
+		self.assertEqual(request.user.loginname, 'newuser')
 
 	def test_confirm_notfound(self):
 		r = self.client.get(path=url_for('signup.signup_confirm', token='notasignuptoken'), follow_redirects=True)
diff --git a/tests/test_user.py b/tests/test_user.py
index 8fae44db05a0e630086d97f0cb88aaa96b635e01..f9fb574b7f5d1f66308d59878d7d24104307278a 100644
--- a/tests/test_user.py
+++ b/tests/test_user.py
@@ -6,7 +6,6 @@ from flask import url_for, session
 # These imports are required, because otherwise we get circular imports?!
 from uffd import ldap, user
 
-from uffd.session.views import get_current_user
 from uffd.user.models import User
 from uffd.role.models import Role
 from uffd import create_app, db
@@ -87,16 +86,46 @@ class TestUserViews(UffdTestCase):
 		user = User.query.get('uid=newuser,ou=users,dc=example,dc=com')
 		roles = sorted([r.name for r in user.roles])
 		self.assertIsNotNone(user)
+		self.assertFalse(user.is_service_user)
 		self.assertEqual(user.loginname, 'newuser')
 		self.assertEqual(user.displayname, 'New User')
 		self.assertEqual(user.mail, 'newuser@example.com')
 		self.assertTrue(user.uid)
 		role1 = Role(name='role1')
-		print('test_new', role1.db_members, role1.members, user.roles)
 		self.assertEqual(roles, ['base', 'role1'])
 		# TODO: confirm Mail is send, login not yet possible
 		#self.assertTrue(ldap.test_user_bind(user.dn, 'newpassword'))
 
+	def test_new_service(self):
+		db.session.add(Role(name='base'))
+		role1 = Role(name='role1')
+		db.session.add(role1)
+		role2 = Role(name='role2')
+		db.session.add(role2)
+		db.session.commit()
+		role1_id = role1.id
+		r = self.client.get(path=url_for('user.show'), follow_redirects=True)
+		dump('user_new_service', r)
+		self.assertEqual(r.status_code, 200)
+		self.assertIsNone(User.query.get('uid=newuser,ou=users,dc=example,dc=com'))
+		r = self.client.post(path=url_for('user.update'),
+			data={'loginname': 'newuser', 'mail': 'newuser@example.com', 'displayname': 'New User',
+			f'role-{role1_id}': '1', 'password': 'newpassword', 'serviceaccount': '1'}, follow_redirects=True)
+		dump('user_new_submit', r)
+		self.assertEqual(r.status_code, 200)
+		user = User.query.get('uid=newuser,ou=users,dc=example,dc=com')
+		roles = sorted([r.name for r in user.roles])
+		self.assertIsNotNone(user)
+		self.assertTrue(user.is_service_user)
+		self.assertEqual(user.loginname, 'newuser')
+		self.assertEqual(user.displayname, 'New User')
+		self.assertEqual(user.mail, 'newuser@example.com')
+		self.assertTrue(user.uid)
+		role1 = Role(name='role1')
+		self.assertEqual(roles, ['role1'])
+		# TODO: confirm Mail is send, login not yet possible
+		#self.assertTrue(ldap.test_user_bind(user.dn, 'newpassword'))
+
 	def test_new_invalid_loginname(self):
 		r = self.client.post(path=url_for('user.update'),
 			data={'loginname': '!newuser', 'mail': 'newuser@example.com', 'displayname': 'New User',
diff --git a/uffd/__init__.py b/uffd/__init__.py
index 0ea45a235ca02783d804563e7add1dc6306f71c1..3a7472c212574fcd463c29647c2c531ee7a07fce 100644
--- a/uffd/__init__.py
+++ b/uffd/__init__.py
@@ -18,6 +18,24 @@ from uffd.template_helper import register_template_helper
 from uffd.navbar import setup_navbar
 # pylint: enable=wrong-import-position
 
+def load_config_file(app, cfg_name, silent=False):
+	cfg_path = os.path.join(app.instance_path, cfg_name)
+	if not os.path.exists(cfg_path):
+		if not silent:
+			raise Exception(f"Config file {cfg_path} not found")
+		return False
+
+	if cfg_path.endswith(".json"):
+		app.config.from_json(cfg_path)
+	elif cfg_path.endswith(".yaml") or cfg_path.endswith(".yml"):
+		import yaml  # pylint: disable=import-outside-toplevel disable=import-error
+		with open(cfg_path, encoding='utf-8') as ymlfile:
+			data = yaml.safe_load(ymlfile)
+		app.config.from_mapping(data)
+	else:
+		app.config.from_pyfile(cfg_path, silent=True)
+	return True
+
 def create_app(test_config=None): # pylint: disable=too-many-locals
 	# create and configure the app
 	app = Flask(__name__, instance_relative_config=False)
@@ -30,21 +48,20 @@ def create_app(test_config=None): # pylint: disable=too-many-locals
 	)
 	app.config.from_pyfile('default_config.cfg')
 
+	# load config
+	if test_config is not None:
+		app.config.from_mapping(test_config)
+	elif os.environ.get("CONFIG_FILENAME"):
+		load_config_file(app, os.environ["CONFIG_FILENAME"], silent=False)
+	else:
+		for cfg_name in ["config.cfg", "config.json", "config.yml", "config.yaml"]:
+			if load_config_file(app, cfg_name, silent=True):
+				break
+
 	register_template_helper(app)
 	setup_navbar(app)
 
-	if not test_config:
-		# load the instance config, if it exists, when not testing
-		app.config.from_pyfile(os.path.join(app.instance_path, 'config.cfg'), silent=True)
-	else:
-		# load the test config if passed in
-		app.config.from_mapping(test_config)
-
-	# ensure the instance folder exists
-	try:
-		os.makedirs(app.instance_path)
-	except OSError:
-		pass
+	os.makedirs(app.instance_path, exist_ok=True)
 
 	db.init_app(app)
 	Migrate(app, db, render_as_batch=True)
diff --git a/uffd/default_config.cfg b/uffd/default_config.cfg
index 559626029e02042fdaa36db1b6d22f937477363d..022657fe3cfac6311f35881939c54b95f2697863 100644
--- a/uffd/default_config.cfg
+++ b/uffd/default_config.cfg
@@ -3,6 +3,8 @@ LDAP_USER_SEARCH_FILTER=[("objectClass", "person")]
 LDAP_USER_OBJECTCLASSES=["top", "inetOrgPerson", "organizationalPerson", "person", "posixAccount"]
 LDAP_USER_MIN_UID=10000
 LDAP_USER_MAX_UID=18999
+LDAP_USER_SERVICE_MIN_UID=19000
+LDAP_USER_SERVICE_MAX_UID=19999
 LDAP_USER_GID=20001
 LDAP_USER_DN_ATTRIBUTE="uid"
 LDAP_USER_UID_ATTRIBUTE="uidNumber"
diff --git a/uffd/invite/templates/invite/list.html b/uffd/invite/templates/invite/list.html
index a371f9e47c0dc44b3fc36f517bda5cffdf8b9978..00fd0ee41c1d352e1134a68dbeb78be6e3be8183 100644
--- a/uffd/invite/templates/invite/list.html
+++ b/uffd/invite/templates/invite/list.html
@@ -21,7 +21,7 @@
 			{% for invite in invites|sort(attribute='created', reverse=True)|sort(attribute='active', reverse=True) %}
 			<tr>
 				<td>
-					{% if invite.creator == get_current_user() and invite.active %}
+					{% if invite.creator == request.user and invite.active %}
 					<a href="{{ url_for('invite.use', token=invite.token) }}"><code>{{ invite.short_token }}</code></a>
 					<button type="button" class="btn btn-link btn-sm p-0 copy-clipboard" data-copy="{{ url_for('invite.use', token=invite.token, _external=True) }}" title="Copy link to clipboard"><i class="fas fa-clipboard"></i></button>
 					<button type="button" class="btn btn-link btn-sm p-0" data-toggle="modal" data-target="#modal-{{ invite.id }}-qrcode" title="Show link as QR code"><i class="fas fa-qrcode"></i></button>
@@ -121,7 +121,7 @@
 				<form action="{{ url_for('invite.disable', invite_id=invite.id) }}" method="POST">
 				<button type="submit" class="btn btn-primary">Disable Link</button>
 				</form>
-				{% elif invite.creator == get_current_user() and not invite.expired and invite.permitted %}
+				{% elif invite.creator == request.user and not invite.expired and invite.permitted %}
 				<form action="{{ url_for('invite.reset', invite_id=invite.id) }}" method="POST">
 				<button type="submit" class="btn btn-primary">Reenable Link</button>
 				</form>
@@ -132,7 +132,7 @@
 </div>
 {% endfor %}
 
-{% for invite in invites if invite.creator == get_current_user() %}
+{% for invite in invites if invite.creator == request.user %}
 <div class="modal" tabindex="-1" id="modal-{{ invite.id }}-qrcode">
 	<div class="modal-dialog">
 		<div class="modal-content">
diff --git a/uffd/invite/templates/invite/use.html b/uffd/invite/templates/invite/use.html
index a51db8858e2538f797cb015fe76e05a1a321fdc4..4d5b53adb56d9afda80647ddbd25dc13c2c00f4e 100644
--- a/uffd/invite/templates/invite/use.html
+++ b/uffd/invite/templates/invite/use.html
@@ -10,7 +10,7 @@
 		<div class="col-12 mb-3">
 			<h2 class="text-center">Invite Link</h2>
 		</div>
-		{% if not is_valid_session() %}
+		{% if not request.user %}
 		<p>Welcome to the CCCV Single-Sign-On!</p>
 		{% endif %}
 
@@ -28,7 +28,7 @@
 			{% endfor %}
 		</ul>
 		{% endif %}
-		{% if is_valid_session() %}
+		{% if request.user %}
 			{% if invite.roles %}
 				<form method="POST" action="{{ url_for("invite.grant", token=invite.token) }}" class="mb-2">
 					<button type="submit" class="btn btn-primary btn-block">Add the roles to your account now</button>
diff --git a/uffd/invite/views.py b/uffd/invite/views.py
index 518a64cb02373b4894330be7c93be607d35664f6..ece81c6465715c7f377f055a972a0d9b09ad2ea7 100644
--- a/uffd/invite/views.py
+++ b/uffd/invite/views.py
@@ -7,7 +7,7 @@ import sqlalchemy
 from uffd.csrf import csrf_protect
 from uffd.database import db
 from uffd.ldap import ldap
-from uffd.session import get_current_user, login_required, is_valid_session
+from uffd.session import login_required
 from uffd.role.models import Role
 from uffd.invite.models import Invite, InviteSignup, InviteGrant
 from uffd.user.models import User
@@ -16,18 +16,16 @@ from uffd.navbar import register_navbar
 from uffd.ratelimit import host_ratelimit, format_delay
 from uffd.signup.views import signup_ratelimit
 
-
 bp = Blueprint('invite', __name__, template_folder='templates', url_prefix='/invite/')
 
 def invite_acl():
-	if not is_valid_session():
+	if not request.user:
 		return False
-	user = get_current_user()
-	if user.is_in_group(current_app.config['ACL_ADMIN_GROUP']):
+	if request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP']):
 		return True
-	if user.is_in_group(current_app.config['ACL_SIGNUP_GROUP']):
+	if request.user.is_in_group(current_app.config['ACL_SIGNUP_GROUP']):
 		return True
-	if Role.query.filter(Role.moderator_group_dn.in_(user.group_dns)).count():
+	if Role.query.filter(Role.moderator_group_dn.in_(request.user.group_dns)).count():
 		return True
 	return False
 
@@ -57,27 +55,25 @@ def reset_acl_filter(user):
 @register_navbar('Invites', icon='link', blueprint=bp, visible=invite_acl)
 @invite_acl_required
 def index():
-	invites = Invite.query.filter(view_acl_filter(get_current_user())).all()
+	invites = Invite.query.filter(view_acl_filter(request.user)).all()
 	return render_template('invite/list.html', invites=invites)
 
 @bp.route('/new')
 @invite_acl_required
 def new():
-	user = get_current_user()
-	if user.is_in_group(current_app.config['ACL_ADMIN_GROUP']):
+	if request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP']):
 		allow_signup = True
 		roles = Role.query.all()
 	else:
-		allow_signup = user.is_in_group(current_app.config['ACL_SIGNUP_GROUP'])
-		roles = Role.query.filter(Role.moderator_group_dn.in_(user.group_dns)).all()
+		allow_signup = request.user.is_in_group(current_app.config['ACL_SIGNUP_GROUP'])
+		roles = Role.query.filter(Role.moderator_group_dn.in_(request.user.group_dns)).all()
 	return render_template('invite/new.html', roles=roles, allow_signup=allow_signup)
 
 @bp.route('/new', methods=['POST'])
 @invite_acl_required
 @csrf_protect(blueprint=bp)
 def new_submit():
-	user = get_current_user()
-	invite = Invite(creator=user,
+	invite = Invite(creator=request.user,
 	                single_use=(request.values['single-use'] == '1'),
 	                valid_until=datetime.datetime.fromisoformat(request.values['valid-until']),
 	                allow_signup=(request.values.get('allow-signup', '0') == '1'))
@@ -101,7 +97,7 @@ def new_submit():
 @invite_acl_required
 @csrf_protect(blueprint=bp)
 def disable(invite_id):
-	invite = Invite.query.filter(view_acl_filter(get_current_user())).filter_by(id=invite_id).first_or_404()
+	invite = Invite.query.filter(view_acl_filter(request.user)).filter_by(id=invite_id).first_or_404()
 	invite.disable()
 	db.session.commit()
 	return redirect(url_for('.index'))
@@ -110,7 +106,7 @@ def disable(invite_id):
 @invite_acl_required
 @csrf_protect(blueprint=bp)
 def reset(invite_id):
-	invite = Invite.query.filter(reset_acl_filter(get_current_user())).filter_by(id=invite_id).first_or_404()
+	invite = Invite.query.filter(reset_acl_filter(request.user)).filter_by(id=invite_id).first_or_404()
 	invite.reset()
 	db.session.commit()
 	return redirect(url_for('.index'))
@@ -128,7 +124,7 @@ def use(token):
 @csrf_protect(blueprint=bp)
 def grant(token):
 	invite = Invite.query.filter_by(token=token).first_or_404()
-	invite_grant = InviteGrant(invite=invite, user=get_current_user())
+	invite_grant = InviteGrant(invite=invite, user=request.user)
 	db.session.add(invite_grant)
 	success, msg = invite_grant.apply()
 	if not success:
diff --git a/uffd/mail/templates/mail_list.html b/uffd/mail/templates/mail/list.html
similarity index 100%
rename from uffd/mail/templates/mail_list.html
rename to uffd/mail/templates/mail/list.html
diff --git a/uffd/mail/templates/mail.html b/uffd/mail/templates/mail/show.html
similarity index 100%
rename from uffd/mail/templates/mail.html
rename to uffd/mail/templates/mail/show.html
diff --git a/uffd/mail/views.py b/uffd/mail/views.py
index 2aaebf559f44465c17ac6bde67796047b48d86e9..6ba83d10a6ffddb2e58f0755254597875991a3bd 100644
--- a/uffd/mail/views.py
+++ b/uffd/mail/views.py
@@ -3,7 +3,7 @@ from flask import Blueprint, render_template, request, url_for, redirect, flash,
 from uffd.navbar import register_navbar
 from uffd.csrf import csrf_protect
 from uffd.ldap import ldap
-from uffd.session import login_required, is_valid_session, get_current_user
+from uffd.session import login_required
 
 from uffd.mail.models import Mail
 
@@ -16,12 +16,12 @@ def mail_acl(): #pylint: disable=inconsistent-return-statements
 		return redirect(url_for('index'))
 
 def mail_acl_check():
-	return is_valid_session() and get_current_user().is_in_group(current_app.config['ACL_ADMIN_GROUP'])
+	return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP'])
 
 @bp.route("/")
 @register_navbar('Mail', icon='envelope', blueprint=bp, visible=mail_acl_check)
 def index():
-	return render_template('mail_list.html', mails=Mail.query.all())
+	return render_template('mail/list.html', mails=Mail.query.all())
 
 @bp.route("/<uid>")
 @bp.route("/new")
@@ -29,7 +29,7 @@ def show(uid=None):
 	mail = Mail()
 	if uid is not None:
 		mail = Mail.query.filter_by(uid=uid).first_or_404()
-	return render_template('mail.html', mail=mail)
+	return render_template('mail/show.html', mail=mail)
 
 @bp.route("/<uid>/update", methods=['POST'])
 @bp.route("/new", methods=['POST'])
diff --git a/uffd/mfa/templates/auth.html b/uffd/mfa/templates/mfa/auth.html
similarity index 100%
rename from uffd/mfa/templates/auth.html
rename to uffd/mfa/templates/mfa/auth.html
diff --git a/uffd/mfa/templates/disable.html b/uffd/mfa/templates/mfa/disable.html
similarity index 100%
rename from uffd/mfa/templates/disable.html
rename to uffd/mfa/templates/mfa/disable.html
diff --git a/uffd/mfa/templates/setup.html b/uffd/mfa/templates/mfa/setup.html
similarity index 100%
rename from uffd/mfa/templates/setup.html
rename to uffd/mfa/templates/mfa/setup.html
diff --git a/uffd/mfa/templates/setup_recovery.html b/uffd/mfa/templates/mfa/setup_recovery.html
similarity index 100%
rename from uffd/mfa/templates/setup_recovery.html
rename to uffd/mfa/templates/mfa/setup_recovery.html
diff --git a/uffd/mfa/templates/setup_totp.html b/uffd/mfa/templates/mfa/setup_totp.html
similarity index 100%
rename from uffd/mfa/templates/setup_totp.html
rename to uffd/mfa/templates/mfa/setup_totp.html
diff --git a/uffd/mfa/views.py b/uffd/mfa/views.py
index f7a3e239c6394471543baf940dbbaa9c01de45f4..7b95479f45e86fe37decbcb3b98fb17c2d5a6ece 100644
--- a/uffd/mfa/views.py
+++ b/uffd/mfa/views.py
@@ -5,7 +5,7 @@ from flask import Blueprint, render_template, session, request, redirect, url_fo
 
 from uffd.database import db
 from uffd.mfa.models import MFAMethod, TOTPMethod, WebauthnMethod, RecoveryCodeMethod
-from uffd.session.views import get_current_user, login_required, pre_mfa_login_required
+from uffd.session.views import login_required, login_required_pre_mfa, set_request_user
 from uffd.user.models import User
 from uffd.csrf import csrf_protect
 from uffd.ratelimit import Ratelimit, format_delay
@@ -17,23 +17,21 @@ mfa_ratelimit = Ratelimit('mfa', 1*60, 3)
 @bp.route('/', methods=['GET'])
 @login_required()
 def setup():
-	user = get_current_user()
-	recovery_methods = RecoveryCodeMethod.query.filter_by(dn=user.dn).all()
-	totp_methods = TOTPMethod.query.filter_by(dn=user.dn).all()
-	webauthn_methods = WebauthnMethod.query.filter_by(dn=user.dn).all()
-	return render_template('setup.html', totp_methods=totp_methods, webauthn_methods=webauthn_methods, recovery_methods=recovery_methods)
+	recovery_methods = RecoveryCodeMethod.query.filter_by(dn=request.user.dn).all()
+	totp_methods = TOTPMethod.query.filter_by(dn=request.user.dn).all()
+	webauthn_methods = WebauthnMethod.query.filter_by(dn=request.user.dn).all()
+	return render_template('mfa/setup.html', totp_methods=totp_methods, webauthn_methods=webauthn_methods, recovery_methods=recovery_methods)
 
 @bp.route('/setup/disable', methods=['GET'])
 @login_required()
 def disable():
-	return render_template('disable.html')
+	return render_template('mfa/disable.html')
 
 @bp.route('/setup/disable', methods=['POST'])
 @login_required()
 @csrf_protect(blueprint=bp)
 def disable_confirm():
-	user = get_current_user()
-	MFAMethod.query.filter_by(dn=user.dn).delete()
+	MFAMethod.query.filter_by(dn=request.user.dn).delete()
 	db.session.commit()
 	return redirect(url_for('mfa.setup'))
 
@@ -43,7 +41,7 @@ def disable_confirm():
 def admin_disable(uid):
 	# Group cannot be checked with login_required kwarg, because the config
 	# variable is not available when the decorator is processed
-	if not get_current_user().is_in_group(current_app.config['ACL_ADMIN_GROUP']):
+	if not request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP']):
 		flash('Access denied')
 		return redirect(url_for('index'))
 	user = User.query.filter_by(uid=uid).one()
@@ -56,34 +54,31 @@ def admin_disable(uid):
 @login_required()
 @csrf_protect(blueprint=bp)
 def setup_recovery():
-	user = get_current_user()
-	for method in RecoveryCodeMethod.query.filter_by(dn=user.dn).all():
+	for method in RecoveryCodeMethod.query.filter_by(dn=request.user.dn).all():
 		db.session.delete(method)
 	methods = []
 	for _ in range(10):
-		method = RecoveryCodeMethod(user)
+		method = RecoveryCodeMethod(request.user)
 		methods.append(method)
 		db.session.add(method)
 	db.session.commit()
-	return render_template('setup_recovery.html', methods=methods)
+	return render_template('mfa/setup_recovery.html', methods=methods)
 
 @bp.route('/setup/totp', methods=['GET'])
 @login_required()
 def setup_totp():
-	user = get_current_user()
-	method = TOTPMethod(user)
+	method = TOTPMethod(request.user)
 	session['mfa_totp_key'] = method.key
-	return render_template('setup_totp.html', method=method, name=request.values['name'])
+	return render_template('mfa/setup_totp.html', method=method, name=request.values['name'])
 
 @bp.route('/setup/totp', methods=['POST'])
 @login_required()
 @csrf_protect(blueprint=bp)
 def setup_totp_finish():
-	user = get_current_user()
-	if not RecoveryCodeMethod.query.filter_by(dn=user.dn).all():
+	if not RecoveryCodeMethod.query.filter_by(dn=request.user.dn).all():
 		flash('Generate recovery codes first!')
 		return redirect(url_for('mfa.setup'))
-	method = TOTPMethod(user, name=request.values['name'], key=session.pop('mfa_totp_key'))
+	method = TOTPMethod(request.user, name=request.values['name'], key=session.pop('mfa_totp_key'))
 	if method.verify(request.form['code']):
 		db.session.add(method)
 		db.session.commit()
@@ -95,8 +90,7 @@ def setup_totp_finish():
 @login_required()
 @csrf_protect(blueprint=bp)
 def delete_totp(id): #pylint: disable=redefined-builtin
-	user = get_current_user()
-	method = TOTPMethod.query.filter_by(dn=user.dn, id=id).first_or_404()
+	method = TOTPMethod.query.filter_by(dn=request.user.dn, id=id).first_or_404()
 	db.session.delete(method)
 	db.session.commit()
 	return redirect(url_for('mfa.setup'))
@@ -124,17 +118,16 @@ if WEBAUTHN_SUPPORTED:
 	@login_required()
 	@csrf_protect(blueprint=bp)
 	def setup_webauthn_begin():
-		user = get_current_user()
-		if not RecoveryCodeMethod.query.filter_by(dn=user.dn).all():
+		if not RecoveryCodeMethod.query.filter_by(dn=request.user.dn).all():
 			abort(403)
-		methods = WebauthnMethod.query.filter_by(dn=user.dn).all()
+		methods = WebauthnMethod.query.filter_by(dn=request.user.dn).all()
 		creds = [method.cred for method in methods]
 		server = get_webauthn_server()
 		registration_data, state = server.register_begin(
 			{
-				"id": user.dn.encode(),
-				"name": user.loginname,
-				"displayName": user.displayname,
+				"id": request.user.dn.encode(),
+				"name": request.user.loginname,
+				"displayName": request.user.displayname,
 			},
 			creds,
 			user_verification='discouraged',
@@ -146,23 +139,21 @@ if WEBAUTHN_SUPPORTED:
 	@login_required()
 	@csrf_protect(blueprint=bp)
 	def setup_webauthn_complete():
-		user = get_current_user()
 		server = get_webauthn_server()
 		data = cbor.loads(request.get_data())[0]
 		client_data = ClientData(data["clientDataJSON"])
 		att_obj = AttestationObject(data["attestationObject"])
 		auth_data = server.register_complete(session["webauthn-state"], client_data, att_obj)
-		method = WebauthnMethod(user, auth_data.credential_data, name=data['name'])
+		method = WebauthnMethod(request.user, auth_data.credential_data, name=data['name'])
 		db.session.add(method)
 		db.session.commit()
 		return cbor.dumps({"status": "OK"})
 
 	@bp.route("/auth/webauthn/begin", methods=["POST"])
-	@pre_mfa_login_required(no_redirect=True)
+	@login_required_pre_mfa(no_redirect=True)
 	def auth_webauthn_begin():
-		user = get_current_user()
 		server = get_webauthn_server()
-		methods = WebauthnMethod.query.filter_by(dn=user.dn).all()
+		methods = WebauthnMethod.query.filter_by(dn=request.user_pre_mfa.dn).all()
 		creds = [method.cred for method in methods]
 		if not creds:
 			abort(404)
@@ -171,11 +162,10 @@ if WEBAUTHN_SUPPORTED:
 		return cbor.dumps(auth_data)
 
 	@bp.route("/auth/webauthn/complete", methods=["POST"])
-	@pre_mfa_login_required(no_redirect=True)
+	@login_required_pre_mfa(no_redirect=True)
 	def auth_webauthn_complete():
-		user = get_current_user()
 		server = get_webauthn_server()
-		methods = WebauthnMethod.query.filter_by(dn=user.dn).all()
+		methods = WebauthnMethod.query.filter_by(dn=request.user_pre_mfa.dn).all()
 		creds = [method.cred for method in methods]
 		if not creds:
 			abort(404)
@@ -195,51 +185,53 @@ if WEBAUTHN_SUPPORTED:
 			signature,
 		)
 		session['user_mfa'] = True
+		set_request_user()
 		return cbor.dumps({"status": "OK"})
 
 @bp.route('/setup/webauthn/<int:id>/delete')
 @login_required()
 @csrf_protect(blueprint=bp)
 def delete_webauthn(id): #pylint: disable=redefined-builtin
-	user = get_current_user()
-	method = WebauthnMethod.query.filter_by(dn=user.dn, id=id).first_or_404()
+	method = WebauthnMethod.query.filter_by(dn=request.user.dn, id=id).first_or_404()
 	db.session.delete(method)
 	db.session.commit()
 	return redirect(url_for('mfa.setup'))
 
 @bp.route('/auth', methods=['GET'])
-@pre_mfa_login_required()
+@login_required_pre_mfa()
 def auth():
-	user = get_current_user()
-	recovery_methods = RecoveryCodeMethod.query.filter_by(dn=user.dn).all()
-	totp_methods = TOTPMethod.query.filter_by(dn=user.dn).all()
-	webauthn_methods = WebauthnMethod.query.filter_by(dn=user.dn).all()
+	recovery_methods = RecoveryCodeMethod.query.filter_by(dn=request.user_pre_mfa.dn).all()
+	totp_methods = TOTPMethod.query.filter_by(dn=request.user_pre_mfa.dn).all()
+	webauthn_methods = WebauthnMethod.query.filter_by(dn=request.user_pre_mfa.dn).all()
 	if not totp_methods and not webauthn_methods:
 		session['user_mfa'] = True
+		set_request_user()
+
 	if session.get('user_mfa'):
 		return redirect(request.values.get('ref', url_for('index')))
-	return render_template('auth.html', ref=request.values.get('ref'), totp_methods=totp_methods,
+	return render_template('mfa/auth.html', ref=request.values.get('ref'), totp_methods=totp_methods,
 			webauthn_methods=webauthn_methods, recovery_methods=recovery_methods)
 
 @bp.route('/auth', methods=['POST'])
-@pre_mfa_login_required()
+@login_required_pre_mfa()
 def auth_finish():
-	user = get_current_user()
-	delay = mfa_ratelimit.get_delay(user.dn)
+	delay = mfa_ratelimit.get_delay(request.user_pre_mfa.dn)
 	if delay:
 		flash('We received too many invalid attempts! Please wait at least %s.'%format_delay(delay))
 		return redirect(url_for('mfa.auth', ref=request.values.get('ref')))
-	recovery_methods = RecoveryCodeMethod.query.filter_by(dn=user.dn).all()
-	totp_methods = TOTPMethod.query.filter_by(dn=user.dn).all()
+	recovery_methods = RecoveryCodeMethod.query.filter_by(dn=request.user_pre_mfa.dn).all()
+	totp_methods = TOTPMethod.query.filter_by(dn=request.user_pre_mfa.dn).all()
 	for method in totp_methods:
 		if method.verify(request.form['code']):
 			session['user_mfa'] = True
+			set_request_user()
 			return redirect(request.values.get('ref', url_for('index')))
 	for method in recovery_methods:
 		if method.verify(request.form['code']):
 			db.session.delete(method)
 			db.session.commit()
 			session['user_mfa'] = True
+			set_request_user()
 			if len(recovery_methods) <= 1:
 				flash('You have exhausted your recovery codes. Please generate new ones now!')
 				return redirect(url_for('mfa.setup'))
@@ -247,6 +239,6 @@ def auth_finish():
 				flash('You only have a few recovery codes remaining. Make sure to generate new ones before they run out.')
 				return redirect(url_for('mfa.setup'))
 			return redirect(request.values.get('ref', url_for('index')))
-	mfa_ratelimit.log(user.dn)
+	mfa_ratelimit.log(request.user_pre_mfa.dn)
 	flash('Two-factor authentication failed')
 	return redirect(url_for('mfa.auth', ref=request.values.get('ref')))
diff --git a/uffd/oauth2/templates/error.html b/uffd/oauth2/templates/oauth2/error.html
similarity index 100%
rename from uffd/oauth2/templates/error.html
rename to uffd/oauth2/templates/oauth2/error.html
diff --git a/uffd/oauth2/templates/logout.html b/uffd/oauth2/templates/oauth2/logout.html
similarity index 100%
rename from uffd/oauth2/templates/logout.html
rename to uffd/oauth2/templates/oauth2/logout.html
diff --git a/uffd/oauth2/views.py b/uffd/oauth2/views.py
index 3ca1b9d96bde823d65141781e1302d0e7b3c2108..12e62febde73d9f22b56a0b2620b0222535fffee 100644
--- a/uffd/oauth2/views.py
+++ b/uffd/oauth2/views.py
@@ -7,7 +7,7 @@ from flask import Blueprint, request, jsonify, render_template, session, redirec
 from flask_oauthlib.provider import OAuth2Provider
 
 from uffd.database import db
-from uffd.session.views import get_current_user, login_required
+from uffd.session.views import login_required
 from .models import OAuth2Client, OAuth2Grant, OAuth2Token
 
 oauth = OAuth2Provider()
@@ -23,7 +23,7 @@ def load_grant(client_id, code):
 @oauth.grantsetter
 def save_grant(client_id, code, oauthreq, *args, **kwargs): # pylint: disable=unused-argument
 	expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=100)
-	grant = OAuth2Grant(user_dn=get_current_user().dn, client_id=client_id,
+	grant = OAuth2Grant(user_dn=request.user.dn, client_id=client_id,
 		code=code['code'], redirect_uri=oauthreq.redirect_uri, expires=expires, _scopes=' '.join(oauthreq.scopes))
 	db.session.add(grant)
 	db.session.commit()
@@ -89,7 +89,7 @@ def authorize(*args, **kwargs): # pylint: disable=unused-argument
 	session['oauth2-clients'] = session.get('oauth2-clients', [])
 	if client.client_id not in session['oauth2-clients']:
 		session['oauth2-clients'].append(client.client_id)
-	return client.access_allowed(get_current_user())
+	return client.access_allowed(request.user)
 
 @bp.route('/token', methods=['GET', 'POST'])
 @oauth.token_handler
@@ -117,7 +117,7 @@ def error():
 	args = dict(request.values)
 	err = args.pop('error', 'unknown')
 	error_description = args.pop('error_description', '')
-	return render_template('error.html', error=err, error_description=error_description, args=args)
+	return render_template('oauth2/error.html', error=err, error_description=error_description, args=args)
 
 @bp.app_url_defaults
 def inject_logout_params(endpoint, values):
@@ -131,4 +131,4 @@ def logout():
 		return redirect(request.values.get('ref', '/'))
 	client_ids = request.values['client_ids'].split(',')
 	clients = [OAuth2Client.from_id(client_id) for client_id in client_ids]
-	return render_template('logout.html', clients=clients)
+	return render_template('oauth2/logout.html', clients=clients)
diff --git a/uffd/role/models.py b/uffd/role/models.py
index 4839b4ffe924dbe94d21bc42e3ece710f352c2de..dad00f53800a3115812f8d57e4d718c5dcb06240 100644
--- a/uffd/role/models.py
+++ b/uffd/role/models.py
@@ -1,4 +1,4 @@
-from sqlalchemy import Column, String, Integer, Text, ForeignKey
+from sqlalchemy import Column, String, Integer, Text, ForeignKey, Boolean
 from sqlalchemy.orm import relationship
 from sqlalchemy.ext.declarative import declared_attr
 
@@ -91,6 +91,11 @@ class Role(db.Model):
 	db_groups = relationship("RoleGroup", backref="role", cascade="all, delete-orphan")
 	groups = DBRelationship('db_groups', Group, RoleGroup, backattr='role', backref='roles')
 
+	# Roles that are managed externally (e.g. by Ansible) can be locked to
+	# prevent accidental editing of name, moderator group, included roles
+	# and groups as well as deletion in the web interface.
+	locked = Column(Boolean(), default=False, nullable=False)
+
 	@property
 	def indirect_members(self):
 		users = set()
diff --git a/uffd/role/templates/role_list.html b/uffd/role/templates/role/list.html
similarity index 100%
rename from uffd/role/templates/role_list.html
rename to uffd/role/templates/role/list.html
diff --git a/uffd/role/templates/role.html b/uffd/role/templates/role/show.html
similarity index 88%
rename from uffd/role/templates/role.html
rename to uffd/role/templates/role/show.html
index 8cf25a5d9c4cf0a310373e6781e536934a2740fe..81d96395bdf6b8f5c2580acd42fb0ea7948d4b52 100644
--- a/uffd/role/templates/role.html
+++ b/uffd/role/templates/role/show.html
@@ -1,13 +1,19 @@
 {% extends 'base.html' %}
 
 {% block body %}
+{% if role.locked %}
+<div class="alert alert-warning" role="alert">
+Name, moderator group, included roles and groups of this role are managed externally. <a href="{{ url_for("role.unlock", roleid=role.id) }}" class="alert-link">Unlock this role</a> to edit them at the risk of having your changes overwritten.
+</div>
+{% endif %}
+
 <form action="{{ url_for("role.update", roleid=role.id) }}" method="POST">
 <div class="align-self-center">
 	<div class="float-sm-right pb-2">
 		<button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> Save</button>
 		<a href="{{ url_for("role.index") }}" class="btn btn-secondary">Cancel</a>
 		{% if role.id %}
-			<a href="{{ url_for("role.delete", roleid=role.id) }}"  onClick="return confirm('Are you sure?');" class="btn btn-danger"><i class="fa fa-trash" aria-hidden="true"></i> Delete</a>
+			<a href="{{ url_for("role.delete", roleid=role.id) }}"  onClick="return confirm('Are you sure?');" class="btn btn-danger {{ 'disabled' if role.locked }}"><i class="fa fa-trash" aria-hidden="true"></i> Delete</a>
 		{% else %}
 			<a href="#" class="btn btn-danger disabled"><i class="fa fa-trash" aria-hidden="true"></i> Delete</a>
 		{% endif %}
@@ -28,7 +34,7 @@
 		<div class="tab-pane fade show active" id="settings" role="tabpanel" aria-labelledby="settings-tab">
 			<div class="form-group col">
 				<label for="role-name">Role Name</label>
-				<input type="text" class="form-control" id="role-name" name="name" value="{{ role.name or '' }}">
+				<input type="text" class="form-control" id="role-name" name="name" value="{{ role.name or '' }}" {{ 'disabled' if role.locked }}>
 				<small class="form-text text-muted">
 				</small>
 			</div>
@@ -40,7 +46,7 @@
 			</div>
 			<div class="form-group col">
 				<label for="moderator-group">Moderator Group</label>
-				<select class="form-control" id="moderator-group" name="moderator-group">
+				<select class="form-control" id="moderator-group" name="moderator-group" {{ 'disabled' if role.locked }}>
 					<option value="" class="text-muted">No Moderator Group</option>
 					{% for group in groups %}
 					<option value="{{ group.dn }}" {{ 'selected' if group == role.moderator_group }}>{{ group.name }}</option>
@@ -82,7 +88,7 @@
 							<td>
 								<div class="form-check">
 									<input class="form-check-input" type="checkbox" id="include-role-{{ r.id }}-checkbox" name="include-role-{{ r.id }}" value="1" aria-label="enabled"
-										{% if r == role %}disabled{% endif %}
+										{% if r == role or role.locked %}disabled{% endif %}
 										{% if r in role.included_roles %}checked{% endif %}>
 								</div>
 							</td>
@@ -121,7 +127,7 @@
 						<tr id="group-{{ group.gid }}">
 							<td>
 								<div class="form-check">
-									<input class="form-check-input" type="checkbox" id="group-{{ group.gid }}-checkbox" name="group-{{ group.gid }}" value="1" aria-label="enabled" {% if group in role.groups %}checked{% endif %}>
+									<input class="form-check-input" type="checkbox" id="group-{{ group.gid }}-checkbox" name="group-{{ group.gid }}" value="1" aria-label="enabled" {% if group in role.groups %}checked{% endif %} {{ 'disabled' if role.locked }}>
 								</div>
 							</td>
 							<td>
diff --git a/uffd/role/views.py b/uffd/role/views.py
index be6ecff5897fe2279f547da1ee9aeb00087ba2d4..a613a191522abe99499b860c20d98954cd8a68c8 100644
--- a/uffd/role/views.py
+++ b/uffd/role/views.py
@@ -7,7 +7,7 @@ from uffd.navbar import register_navbar
 from uffd.csrf import csrf_protect
 from uffd.role.models import Role
 from uffd.user.models import User, Group
-from uffd.session import get_current_user, login_required, is_valid_session
+from uffd.session import login_required
 from uffd.database import db
 from uffd.ldap import ldap
 
@@ -44,23 +44,23 @@ def role_acl(): #pylint: disable=inconsistent-return-statements
 		return redirect(url_for('index'))
 
 def role_acl_check():
-	return is_valid_session() and get_current_user().is_in_group(current_app.config['ACL_ADMIN_GROUP'])
+	return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP'])
 
 @bp.route("/")
 @register_navbar('Roles', icon='key', blueprint=bp, visible=role_acl_check)
 def index():
-	return render_template('role_list.html', roles=Role.query.all())
+	return render_template('role/list.html', roles=Role.query.all())
 
 @bp.route("/new")
 def new():
-	return render_template('role.html', role=Role(), groups=Group.query.all(), roles=Role.query.all())
+	return render_template('role/show.html', role=Role(), groups=Group.query.all(), roles=Role.query.all())
 
 @bp.route("/<int:roleid>")
 def show(roleid=None):
 	# prefetch all users so the ldap orm can cache them and doesn't run one ldap query per user
 	User.query.all()
 	role = Role.query.filter_by(id=roleid).one()
-	return render_template('role.html', role=role, groups=Group.query.all(), roles=Role.query.all())
+	return render_template('role/show.html', role=role, groups=Group.query.all(), roles=Role.query.all())
 
 @bp.route("/<int:roleid>/update", methods=['POST'])
 @bp.route("/new", methods=['POST'])
@@ -71,22 +71,23 @@ def update(roleid=None):
 		db.session.add(role)
 	else:
 		role = Role.query.filter_by(id=roleid).one()
-	role.name = request.values['name']
 	role.description = request.values['description']
-	if not request.values['moderator-group']:
-		role.moderator_group_dn = None
-	else:
-		role.moderator_group = Group.query.get(request.values['moderator-group'])
-	for included_role in Role.query.all():
-		if included_role != role and request.values.get('include-role-{}'.format(included_role.id)):
-			role.included_roles.append(included_role)
-		elif included_role in role.included_roles:
-			role.included_roles.remove(included_role)
-	for group in Group.query.all():
-		if request.values.get('group-{}'.format(group.gid), False):
-			role.groups.add(group)
+	if not role.locked:
+		role.name = request.values['name']
+		if not request.values['moderator-group']:
+			role.moderator_group_dn = None
 		else:
-			role.groups.discard(group)
+			role.moderator_group = Group.query.get(request.values['moderator-group'])
+		for included_role in Role.query.all():
+			if included_role != role and request.values.get('include-role-{}'.format(included_role.id)):
+				role.included_roles.append(included_role)
+			elif included_role in role.included_roles:
+				role.included_roles.remove(included_role)
+		for group in Group.query.all():
+			if request.values.get('group-{}'.format(group.gid), False):
+				role.groups.add(group)
+			else:
+				role.groups.discard(group)
 	role.update_member_groups()
 	db.session.commit()
 	ldap.session.commit()
@@ -96,6 +97,9 @@ def update(roleid=None):
 @csrf_protect(blueprint=bp)
 def delete(roleid):
 	role = Role.query.filter_by(id=roleid).one()
+	if role.locked:
+		flash('Locked roles cannot be deleted')
+		return redirect(url_for('role.show', roleid=role.id))
 	oldmembers = set(role.members).union(role.indirect_members)
 	role.members.clear()
 	db.session.delete(role)
@@ -104,3 +108,11 @@ def delete(roleid):
 	db.session.commit()
 	ldap.session.commit()
 	return redirect(url_for('role.index'))
+
+@bp.route("/<int:roleid>/unlock")
+@csrf_protect(blueprint=bp)
+def unlock(roleid):
+	role = Role.query.filter_by(id=roleid).one()
+	role.locked = False
+	db.session.commit()
+	return redirect(url_for('role.show', roleid=role.id))
diff --git a/uffd/rolemod/views.py b/uffd/rolemod/views.py
index 174d923ba15be0db5375c67b478c343a9234084f..d9c5f83482d6764a16b8f25cc2283d831893d29a 100644
--- a/uffd/rolemod/views.py
+++ b/uffd/rolemod/views.py
@@ -4,14 +4,14 @@ from uffd.navbar import register_navbar
 from uffd.csrf import csrf_protect
 from uffd.role.models import Role
 from uffd.user.models import User
-from uffd.session import get_current_user, login_required, is_valid_session
+from uffd.session import login_required
 from uffd.database import db
 from uffd.ldap import ldap
 
 bp = Blueprint('rolemod', __name__, template_folder='templates', url_prefix='/rolemod/')
 
 def user_is_rolemod():
-	return is_valid_session() and Role.query.filter(Role.moderator_group_dn.in_(get_current_user().group_dns)).count()
+	return request.user and Role.query.filter(Role.moderator_group_dn.in_(request.user.group_dns)).count()
 
 @bp.before_request
 @login_required()
@@ -23,7 +23,7 @@ def acl_check(): #pylint: disable=inconsistent-return-statements
 @bp.route("/")
 @register_navbar('Moderation', icon='user-lock', blueprint=bp, visible=user_is_rolemod)
 def index():
-	roles = Role.query.filter(Role.moderator_group_dn.in_(get_current_user().group_dns)).all()
+	roles = Role.query.filter(Role.moderator_group_dn.in_(request.user.group_dns)).all()
 	return render_template('rolemod/list.html', roles=roles)
 
 @bp.route("/<int:role_id>")
@@ -31,7 +31,7 @@ def show(role_id):
 	# prefetch all users so the ldap orm can cache them and doesn't run one ldap query per user
 	User.query.all()
 	role = Role.query.get_or_404(role_id)
-	if role.moderator_group not in get_current_user().groups:
+	if role.moderator_group not in request.user.groups:
 		flash('Access denied')
 		return redirect(url_for('index'))
 	return render_template('rolemod/show.html', role=role)
@@ -40,7 +40,7 @@ def show(role_id):
 @csrf_protect(blueprint=bp)
 def update(role_id):
 	role = Role.query.get_or_404(role_id)
-	if role.moderator_group not in get_current_user().groups:
+	if role.moderator_group not in request.user.groups:
 		flash('Access denied')
 		return redirect(url_for('index'))
 	if request.form['description'] != role.description:
@@ -55,7 +55,7 @@ def update(role_id):
 @csrf_protect(blueprint=bp)
 def delete_member(role_id, member_dn):
 	role = Role.query.get_or_404(role_id)
-	if role.moderator_group not in get_current_user().groups:
+	if role.moderator_group not in request.user.groups:
 		flash('Access denied')
 		return redirect(url_for('index'))
 	member = User.query.get_or_404(member_dn)
diff --git a/uffd/selfservice/templates/forgot_password.html b/uffd/selfservice/templates/selfservice/forgot_password.html
similarity index 100%
rename from uffd/selfservice/templates/forgot_password.html
rename to uffd/selfservice/templates/selfservice/forgot_password.html
diff --git a/uffd/selfservice/templates/mailverification.mail.txt b/uffd/selfservice/templates/selfservice/mailverification.mail.txt
similarity index 100%
rename from uffd/selfservice/templates/mailverification.mail.txt
rename to uffd/selfservice/templates/selfservice/mailverification.mail.txt
diff --git a/uffd/selfservice/templates/newuser.mail.txt b/uffd/selfservice/templates/selfservice/newuser.mail.txt
similarity index 100%
rename from uffd/selfservice/templates/newuser.mail.txt
rename to uffd/selfservice/templates/selfservice/newuser.mail.txt
diff --git a/uffd/selfservice/templates/passwordreset.mail.txt b/uffd/selfservice/templates/selfservice/passwordreset.mail.txt
similarity index 100%
rename from uffd/selfservice/templates/passwordreset.mail.txt
rename to uffd/selfservice/templates/selfservice/passwordreset.mail.txt
diff --git a/uffd/selfservice/templates/self.html b/uffd/selfservice/templates/selfservice/self.html
similarity index 84%
rename from uffd/selfservice/templates/self.html
rename to uffd/selfservice/templates/selfservice/self.html
index 7cb7bf5652eaea2a3904d28fefbd02609498fc99..e3ce9467ef211d38109cea4a0839edf4698f595f 100644
--- a/uffd/selfservice/templates/self.html
+++ b/uffd/selfservice/templates/selfservice/self.html
@@ -40,6 +40,22 @@
 		<label for="user-password2">Password Repeat</label>
 		<input type="password" class="form-control" id="user-password2" name="password2" placeholder="●●●●●●●●">
 	</div>
+	<div class="form-group">
+		{% if user.roles_recursive|length %}
+			{% if user.roles_recursive|length == 1 %}
+				You have this role:
+			{% else %}
+				You currently have these roles:
+			{% endif %}
+			<ul>
+				{% for role in user.roles_recursive|sort(attribute="name") %}
+				<li>{{ role.name }}</li>
+				{% endfor %}
+			</ul>
+		{% else %}
+			You currently don't have any roles.
+		{% endif %}
+	</div>
 	<div class="form-group col-md-12">
 		<button type="submit" class="btn btn-primary float-right"><i class="fa fa-save" aria-hidden="true"></i> Save</button>
 	</div>
diff --git a/uffd/selfservice/templates/set_password.html b/uffd/selfservice/templates/selfservice/set_password.html
similarity index 100%
rename from uffd/selfservice/templates/set_password.html
rename to uffd/selfservice/templates/selfservice/set_password.html
diff --git a/uffd/selfservice/views.py b/uffd/selfservice/views.py
index 44556f363fb5273fe2decd47296e6d6e0cec588a..d469ef69310e27f1f7c60a47293fb923a96effb5 100644
--- a/uffd/selfservice/views.py
+++ b/uffd/selfservice/views.py
@@ -9,7 +9,7 @@ from flask import Blueprint, render_template, request, url_for, redirect, flash,
 from uffd.navbar import register_navbar
 from uffd.csrf import csrf_protect
 from uffd.user.models import User
-from uffd.session import get_current_user, login_required, is_valid_session
+from uffd.session import login_required
 from uffd.selfservice.models import PasswordToken, MailToken
 from uffd.database import db
 from uffd.ldap import ldap
@@ -20,17 +20,17 @@ bp = Blueprint("selfservice", __name__, template_folder='templates', url_prefix=
 reset_ratelimit = Ratelimit('passwordreset', 1*60*60, 3)
 
 @bp.route("/")
-@register_navbar('Selfservice', icon='portrait', blueprint=bp, visible=is_valid_session)
+@register_navbar('Selfservice', icon='portrait', blueprint=bp, visible=lambda: bool(request.user))
 @login_required()
 def index():
-	return render_template('self.html', user=get_current_user())
+	return render_template('selfservice/self.html', user=request.user)
 
 @bp.route("/update", methods=(['POST']))
 @csrf_protect(blueprint=bp)
 @login_required()
 def update():
 	password_changed = False
-	user = get_current_user()
+	user = request.user
 	if request.values['displayname'] != user.displayname:
 		if user.set_displayname(request.values['displayname']):
 			flash('Display name changed.')
@@ -57,7 +57,7 @@ def update():
 @bp.route("/passwordreset", methods=(['GET', 'POST']))
 def forgot_password():
 	if request.method == 'GET':
-		return render_template('forgot_password.html')
+		return render_template('selfservice/forgot_password.html')
 
 	loginname = request.values['loginname']
 	mail = request.values['mail']
@@ -87,17 +87,17 @@ def token_password(token):
 			db.session.commit()
 		return redirect(url_for('session.login'))
 	if request.method == 'GET':
-		return render_template('set_password.html', token=token)
+		return render_template('selfservice/set_password.html', token=token)
 	if not request.values['password1']:
 		flash('You need to set a password, please try again.')
-		return render_template('set_password.html', token=token)
+		return render_template('selfservice/set_password.html', token=token)
 	if not request.values['password1'] == request.values['password2']:
 		flash('Passwords do not match, please try again.')
-		return render_template('set_password.html', token=token)
+		return render_template('selfservice/set_password.html', token=token)
 	user = User.query.filter_by(loginname=dbtoken.loginname).one()
 	if not user.set_password(request.values['password1']):
 		flash('Password ist not valid, please try again.')
-		return render_template('set_password.html', token=token)
+		return render_template('selfservice/set_password.html', token=token)
 	db.session.delete(dbtoken)
 	flash('New password set')
 	ldap.session.commit()
@@ -137,7 +137,7 @@ def send_mail_verification(loginname, newmail):
 	user = User.query.filter_by(loginname=loginname).one()
 
 	msg = EmailMessage()
-	msg.set_content(render_template('mailverification.mail.txt', user=user, token=token.token))
+	msg.set_content(render_template('selfservice/mailverification.mail.txt', user=user, token=token.token))
 	msg['Subject'] = 'Mail verification'
 	send_mail(newmail, msg)
 
@@ -153,10 +153,10 @@ def send_passwordreset(user, new=False):
 
 	msg = EmailMessage()
 	if new:
-		msg.set_content(render_template('newuser.mail.txt', user=user, token=token.token))
+		msg.set_content(render_template('selfservice/newuser.mail.txt', user=user, token=token.token))
 		msg['Subject'] = 'Welcome to the CCCV infrastructure'
 	else:
-		msg.set_content(render_template('passwordreset.mail.txt', user=user, token=token.token))
+		msg.set_content(render_template('selfservice/passwordreset.mail.txt', user=user, token=token.token))
 		msg['Subject'] = 'Password reset'
 	send_mail(user.mail, msg)
 
diff --git a/uffd/services/templates/overview.html b/uffd/services/templates/services/overview.html
similarity index 100%
rename from uffd/services/templates/overview.html
rename to uffd/services/templates/services/overview.html
diff --git a/uffd/services/views.py b/uffd/services/views.py
index f4f9a805156966300b07748ac68f3dbd17a83d36..585de42c7955661d64ea74715d836ea2c6e2eabf 100644
--- a/uffd/services/views.py
+++ b/uffd/services/views.py
@@ -1,7 +1,6 @@
-from flask import Blueprint, render_template, current_app, abort
+from flask import Blueprint, render_template, current_app, abort, request
 
 from uffd.navbar import register_navbar
-from uffd.session import is_valid_session, get_current_user
 
 bp = Blueprint("services", __name__, template_folder='templates', url_prefix='/services')
 
@@ -69,25 +68,19 @@ def get_services(user=None):
 	return services
 
 def services_visible():
-	user = None
-	if is_valid_session():
-		user = get_current_user()
-	return len(get_services(user)) > 0
+	return len(get_services(request.user)) > 0
 
 @bp.route("/")
 @register_navbar('Services', icon='sitemap', blueprint=bp, visible=services_visible)
 def index():
-	user = None
-	if is_valid_session():
-		user = get_current_user()
-	services = get_services(user)
+	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 user):
+	if not (current_app.config["SERVICES_BANNER_PUBLIC"] or request.user):
 		banner = None
 
-	return render_template('overview.html', user=user, services=services, banner=banner)
+	return render_template('services/overview.html', user=request.user, services=services, banner=banner)
diff --git a/uffd/session/__init__.py b/uffd/session/__init__.py
index 5cddcdc6892a9c2e0a85e1047733432499b6d171..0e571f3a6aec00dcb8dd2d7eff8788441b735f3d 100644
--- a/uffd/session/__init__.py
+++ b/uffd/session/__init__.py
@@ -1,3 +1,3 @@
-from .views import bp as bp_ui, get_current_user, login_required, is_valid_session, set_session
+from .views import bp as bp_ui, login_required, set_session
 
 bp = [bp_ui]
diff --git a/uffd/session/templates/login.html b/uffd/session/templates/session/login.html
similarity index 100%
rename from uffd/session/templates/login.html
rename to uffd/session/templates/session/login.html
diff --git a/uffd/session/views.py b/uffd/session/views.py
index 02a70d4e5edcbb42dfd84d24b2d99ee31b586538..2c57bc6df93c5ffae966ee219759c5a6c3cdd932 100644
--- a/uffd/session/views.py
+++ b/uffd/session/views.py
@@ -12,6 +12,21 @@ bp = Blueprint("session", __name__, template_folder='templates', url_prefix='/')
 
 login_ratelimit = Ratelimit('login', 1*60, 3)
 
+@bp.before_app_request
+def set_request_user():
+	request.user = None
+	request.user_pre_mfa = None
+	if 'user_dn' not in session:
+		return
+	if 'logintime' not in session:
+		return
+	if datetime.datetime.now().timestamp() > session['logintime'] + current_app.config['SESSION_LIFETIME_SECONDS']:
+		return
+	user = User.query.get(session['user_dn'])
+	request.user_pre_mfa = user
+	if session.get('user_mfa'):
+		request.user = user
+
 def login_get_user(loginname, password):
 	dn = User(loginname=loginname).dn
 
@@ -60,7 +75,7 @@ def set_session(user, password='', skip_mfa=False):
 @bp.route("/login", methods=('GET', 'POST'))
 def login():
 	if request.method == 'GET':
-		return render_template('login.html', ref=request.values.get('ref'))
+		return render_template('session/login.html', ref=request.values.get('ref'))
 
 	username = request.form['loginname']
 	password = request.form['password']
@@ -71,47 +86,24 @@ def login():
 			flash('We received too many invalid login attempts for this user! Please wait at least %s.'%format_delay(login_delay))
 		else:
 			flash('We received too many requests from your ip address/network! Please wait at least %s.'%format_delay(host_delay))
-		return render_template('login.html', ref=request.values.get('ref'))
+		return render_template('session/login.html', ref=request.values.get('ref'))
 	user = login_get_user(username, password)
 	if user is None:
 		login_ratelimit.log(username)
 		host_ratelimit.log()
 		flash('Login name or password is wrong')
-		return render_template('login.html', ref=request.values.get('ref'))
+		return render_template('session/login.html', ref=request.values.get('ref'))
 	if not user.is_in_group(current_app.config['ACL_SELFSERVICE_GROUP']):
 		flash('You do not have access to this service')
-		return render_template('login.html', ref=request.values.get('ref'))
+		return render_template('session/login.html', ref=request.values.get('ref'))
 	set_session(user, password=password)
 	return redirect(url_for('mfa.auth', ref=request.values.get('ref', url_for('index'))))
 
-def get_current_user():
-	if 'user_dn' not in session:
-		return None
-	return User.query.get(session['user_dn'])
-bp.add_app_template_global(get_current_user)
-
-def login_valid():
-	user = get_current_user()
-	if user is None:
-		return False
-	if datetime.datetime.now().timestamp() > session['logintime'] + current_app.config['SESSION_LIFETIME_SECONDS']:
-		return False
-	return True
-
-def is_valid_session():
-	if not login_valid():
-		return False
-	if not session.get('user_mfa'):
-		return False
-	return True
-bp.add_app_template_global(is_valid_session)
-
-def pre_mfa_login_required(no_redirect=False):
+def login_required_pre_mfa(no_redirect=False):
 	def wrapper(func):
 		@functools.wraps(func)
 		def decorator(*args, **kwargs):
-			if not login_valid() or datetime.datetime.now().timestamp() > session['logintime'] + 10*60:
-				session.clear()
+			if not request.user_pre_mfa:
 				if no_redirect:
 					abort(403)
 				flash('You need to login first')
@@ -124,12 +116,12 @@ def login_required(group=None):
 	def wrapper(func):
 		@functools.wraps(func)
 		def decorator(*args, **kwargs):
-			if not login_valid():
+			if not request.user_pre_mfa:
 				flash('You need to login first')
 				return redirect(url_for('session.login', ref=request.url))
-			if not session.get('user_mfa'):
+			if not request.user:
 				return redirect(url_for('mfa.auth', ref=request.url))
-			if not get_current_user().is_in_group(group):
+			if not request.user.is_in_group(group):
 				flash('Access denied')
 				return redirect(url_for('index'))
 			return func(*args, **kwargs)
diff --git a/uffd/templates/base.html b/uffd/templates/base.html
index 023c3328aa64a18978935b04335aea2b4e92bab3..807af010be4f73c6d3ad2a8e38449c1cbbb4b6a8 100644
--- a/uffd/templates/base.html
+++ b/uffd/templates/base.html
@@ -67,7 +67,7 @@
 					</li>
 					{% endfor %}
 				</ul>
-				{% if is_valid_session() %}
+				{% if request.user %}
 				<ul class="navbar-nav ml-auto">
 					<li class="nav-item">
 						<a class="nav-link" href="{{ url_for("session.logout") }}">
diff --git a/uffd/user/models.py b/uffd/user/models.py
index c0ce603e3d39fdf4dd49b4926ff9f957fefdcd93..2749884f85f9e77ed2f5a71cde3657f5e412dd46 100644
--- a/uffd/user/models.py
+++ b/uffd/user/models.py
@@ -8,13 +8,18 @@ from ldap3.utils.hashed import hashed, HASHED_SALTED_SHA512
 from uffd.ldap import ldap
 from uffd.lazyconfig import lazyconfig_str, lazyconfig_list
 
-def get_next_uid():
-	max_uid = current_app.config['LDAP_USER_MIN_UID']
+def get_next_uid(service=False):
+	if service:
+		new_uid_min = current_app.config['LDAP_USER_SERVICE_MIN_UID']
+		new_uid_max = current_app.config['LDAP_USER_SERVICE_MAX_UID']
+	else:
+		new_uid_min = current_app.config['LDAP_USER_MIN_UID']
+		new_uid_max = current_app.config['LDAP_USER_MAX_UID']
+	next_uid = new_uid_min
 	for user in User.query.all():
-		if user.uid <= current_app.config['LDAP_USER_MAX_UID']:
-			max_uid = max(user.uid, max_uid)
-	next_uid = max_uid + 1
-	if next_uid > current_app.config['LDAP_USER_MAX_UID']:
+		if user.uid <= new_uid_max:
+			next_uid = max(next_uid, user.uid + 1)
+	if next_uid > new_uid_max:
 		raise Exception('No free uid found')
 	return next_uid
 
@@ -50,6 +55,18 @@ class BaseUser(ldap.Model):
 	def group_dns(self):
 		return [group.dn for group in self.groups]
 
+	@property
+	def is_service_user(self):
+		if self.uid is None:
+			return None
+		return self.uid >= current_app.config['LDAP_USER_SERVICE_MIN_UID'] and self.uid <= current_app.config['LDAP_USER_SERVICE_MAX_UID']
+
+	@is_service_user.setter
+	def is_service_user(self, value):
+		assert self.uid is None
+		if value:
+			self.uid = get_next_uid(service=True)
+
 	def add_default_attributes(self):
 		for name, values in current_app.config['LDAP_USER_DEFAULT_ATTRIBUTES'].items():
 			if self.ldap_object.getattr(name):
diff --git a/uffd/user/templates/group_list.html b/uffd/user/templates/group/list.html
similarity index 100%
rename from uffd/user/templates/group_list.html
rename to uffd/user/templates/group/list.html
diff --git a/uffd/user/templates/group.html b/uffd/user/templates/group/show.html
similarity index 100%
rename from uffd/user/templates/group.html
rename to uffd/user/templates/group/show.html
diff --git a/uffd/user/templates/user_list.html b/uffd/user/templates/user/list.html
similarity index 94%
rename from uffd/user/templates/user_list.html
rename to uffd/user/templates/user/list.html
index ddcaab3e89023d8fce96db1bf59a7b45bf5eeccd..93d7da5264b7e5e1625ed6f724bd9f2e7cff23d3 100644
--- a/uffd/user/templates/user_list.html
+++ b/uffd/user/templates/user/list.html
@@ -30,12 +30,15 @@
 						<a href="{{ url_for("user.show", uid=user.uid) }}">
 							{{ user.loginname }}
 						</a>
+						{% if user.is_service_user %}
+						<span class="badge badge-secondary">service</span>
+						{% endif %}
 					</td>
 					<td>
 						{{ user.displayname }}
 					</td>
 					<td>
-					{% for role in user.roles|sort(attribute="name") if not role.name in config["ROLES_BASEROLES"] %}
+					{% for role in user.roles|sort(attribute="name") if not role.name in config["ROLES_BASEROLES"] or user.is_service_user %}
 						<a href="{{ url_for("role.show", roleid=role.id) }}">{{ role.name }}</a>{% if not loop.last %}, {% endif %}
 					{% endfor %}
 					</td>
diff --git a/uffd/user/templates/user.html b/uffd/user/templates/user/show.html
similarity index 89%
rename from uffd/user/templates/user.html
rename to uffd/user/templates/user/show.html
index b2feedf9a0417e59636d3c19bc8303342a211ea2..d638f7fc287820a287189010c48f7e6577b96849 100644
--- a/uffd/user/templates/user.html
+++ b/uffd/user/templates/user/show.html
@@ -27,13 +27,26 @@
 	<div class="tab-content border mb-2 pt-2" id="tabcontent">
 		<div class="tab-pane fade show active" id="profile" role="tabpanel" aria-labelledby="roles-tab">
 			<div class="form-group col">
-				<label for="user-uid">uid</label>
+				<label for="user-uid">
+					uid
+					{% if user.is_service_user %}
+					<span class="badge badge-secondary">service</span>
+					{% endif %}
+				</label>
 				{% if user.uid %}
 				<input type="number" class="form-control" id="user-uid" name="uid" value="{{ user.uid or '' }}" readonly>
 				{% else %}
 				<input type="text" class="form-control" id="user-uid" name="uid" placeholder="will be choosen" readonly>
 				{% endif %}
 			</div>
+			{% if not user.uid %}
+			<div class="form-group col">
+				<div class="form-check">
+					<input class="form-check-input" type="checkbox" id="user-serviceaccount" name="serviceaccount" value="1" aria-label="enabled">
+					<label class="form-check-label" for="user-serviceaccount">Service User</label>
+				</div>
+			</div>
+			{% endif %}
 			<div class="form-group col">
 				<label for="user-loginname">Login Name</label>
 				<input type="text" class="form-control" id="user-loginname" name="loginname" value="{{ user.loginname or '' }}" {% if user.uid %}readonly{% endif %}>
@@ -92,8 +105,8 @@
 							<td>
 								<div class="form-check">
 									<input class="form-check-input" type="checkbox" id="role-{{ role.id }}-checkbox" name="role-{{ role.id }}" value="1" aria-label="enabled"
-										{% if user in role.members or role.name in config["ROLES_BASEROLES"] %}checked {% endif %}
-										{% if role.name in config["ROLES_BASEROLES"] %}disabled {% endif %}>
+										{% if user in role.members %}checked {% endif %}
+										{% if role.name in config["ROLES_BASEROLES"] and not user.is_service_user %}disabled {% endif %}>
 								</div>
 							</td>
 							<td>
diff --git a/uffd/user/views_group.py b/uffd/user/views_group.py
index 22fdd9e9d9def67a36fb5c18e2351878e61ebc0f..d4318b3cd86df28be912e3f7bcd0351bc4bed16a 100644
--- a/uffd/user/views_group.py
+++ b/uffd/user/views_group.py
@@ -1,7 +1,7 @@
-from flask import Blueprint, render_template, url_for, redirect, flash, current_app
+from flask import Blueprint, render_template, url_for, redirect, flash, current_app, request
 
 from uffd.navbar import register_navbar
-from uffd.session import login_required, is_valid_session, get_current_user
+from uffd.session import login_required
 
 from .models import Group
 
@@ -14,13 +14,13 @@ def group_acl(): #pylint: disable=inconsistent-return-statements
 		return redirect(url_for('index'))
 
 def group_acl_check():
-	return is_valid_session() and get_current_user().is_in_group(current_app.config['ACL_ADMIN_GROUP'])
+	return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP'])
 
 @bp.route("/")
 @register_navbar('Groups', icon='layer-group', blueprint=bp, visible=group_acl_check)
 def index():
-	return render_template('group_list.html', groups=Group.query.all())
+	return render_template('group/list.html', groups=Group.query.all())
 
 @bp.route("/<int:gid>")
 def show(gid):
-	return render_template('group.html', group=Group.query.filter_by(gid=gid).first_or_404())
+	return render_template('group/show.html', group=Group.query.filter_by(gid=gid).first_or_404())
diff --git a/uffd/user/views_user.py b/uffd/user/views_user.py
index e25f89e7df1a197701ca17c8a52dc33c37b7bf77..a76beceb53ff8defc75b62b74db756ec9dbc981a 100644
--- a/uffd/user/views_user.py
+++ b/uffd/user/views_user.py
@@ -6,7 +6,7 @@ from flask import Blueprint, render_template, request, url_for, redirect, flash,
 from uffd.navbar import register_navbar
 from uffd.csrf import csrf_protect
 from uffd.selfservice import send_passwordreset
-from uffd.session import login_required, is_valid_session, get_current_user
+from uffd.session import login_required
 from uffd.role.models import Role
 from uffd.database import db
 from uffd.ldap import ldap, LDAPCommitError
@@ -22,18 +22,18 @@ def user_acl(): #pylint: disable=inconsistent-return-statements
 		return redirect(url_for('index'))
 
 def user_acl_check():
-	return is_valid_session() and get_current_user().is_in_group(current_app.config['ACL_ADMIN_GROUP'])
+	return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP'])
 
 @bp.route("/")
 @register_navbar('Users', icon='users', blueprint=bp, visible=user_acl_check)
 def index():
-	return render_template('user_list.html', users=User.query.all())
+	return render_template('user/list.html', users=User.query.all())
 
 @bp.route("/<int:uid>")
 @bp.route("/new")
 def show(uid=None):
 	user = User() if uid is None else User.query.filter_by(uid=uid).first_or_404()
-	return render_template('user.html', user=user, roles=Role.query.all())
+	return render_template('user/show.html', user=user, roles=Role.query.all())
 
 @bp.route("/<int:uid>/update", methods=['POST'])
 @bp.route("/new", methods=['POST'])
@@ -42,6 +42,8 @@ def update(uid=None):
 	if uid is None:
 		user = User()
 		ignore_blacklist = request.form.get('ignore-loginname-blacklist', False)
+		if request.form.get('serviceaccount'):
+			user.is_service_user = True
 		if not user.set_loginname(request.form['loginname'], ignore_blacklist=ignore_blacklist):
 			flash('Login name does not meet requirements')
 			return redirect(url_for('user.show'))
@@ -60,14 +62,19 @@ def update(uid=None):
 	ldap.session.add(user)
 	user.roles.clear()
 	for role in Role.query.all():
-		if request.values.get('role-{}'.format(role.id), False) or role.name in current_app.config["ROLES_BASEROLES"]:
+		if request.values.get('role-{}'.format(role.id), False):
+			user.roles.add(role)
+		elif not user.is_service_user and role.name in current_app.config["ROLES_BASEROLES"]:
 			user.roles.add(role)
 	user.update_groups()
 	ldap.session.commit()
 	db.session.commit()
 	if uid is None:
-		send_passwordreset(user, new=True)
-		flash('User created. We sent the user a password reset link by mail')
+		if user.is_service_user:
+			flash('Service user created')
+		else:
+			send_passwordreset(user, new=True)
+			flash('User created. We sent the user a password reset link by mail')
 	else:
 		flash('User updated')
 	return redirect(url_for('user.show', uid=user.uid))