From 2d0ed84b2b57a4fd003ed5455bad344c9fbd2feb Mon Sep 17 00:00:00 2001
From: Julian Rother <julian@cccv.de>
Date: Sat, 4 Sep 2021 14:25:15 +0200
Subject: [PATCH] Dedicated error page for permission errors

Prior to this change permission errors (i.e. the user is logged in but does
not have a required group) were reported with flash('Access denied') and a
redirect to the selfservice index page. This causes two problems: The error
is reported with HTTP status 301/200 which is difficult to check for in tests.
This can also cause redirect loops as soon as the selfservice uses more
differentiated permission checks (see #104).

With this change a dedicated error page is displayed in place the requested
page and the HTTP status 403 is returned. This is implemented with
flask's errorhandler concept for 403.
---
 tests/test_invite.py                         |   5 +-
 tests/test_role.py                           |  14 ++-
 tests/test_rolemod.py                        |  15 ++-
 uffd/__init__.py                             |   8 +-
 uffd/invite/views.py                         |   5 +-
 uffd/mail/views.py                           |   7 +-
 uffd/mfa/views.py                            |   3 +-
 uffd/oauth2/views.py                         |  14 +--
 uffd/role/views.py                           |   7 +-
 uffd/rolemod/views.py                        |  16 +--
 uffd/session/views.py                        |   3 +-
 uffd/templates/403.html                      |  23 ++++
 uffd/translations/de/LC_MESSAGES/messages.mo | Bin 31076 -> 31369 bytes
 uffd/translations/de/LC_MESSAGES/messages.po | 111 ++++++++++---------
 uffd/user/views_group.py                     |   9 +-
 uffd/user/views_user.py                      |   7 +-
 16 files changed, 135 insertions(+), 112 deletions(-)
 create mode 100644 uffd/templates/403.html

diff --git a/tests/test_invite.py b/tests/test_invite.py
index 7c6bf2fb..f3d652ac 100644
--- a/tests/test_invite.py
+++ b/tests/test_invite.py
@@ -326,8 +326,7 @@ class TestInviteAdminViews(UffdTestCase):
 		self.login_as('user')
 		r = self.client.get(path=url_for('invite.index'), follow_redirects=True)
 		dump('invite_index_noaccess', r)
-		self.assertEqual(r.status_code, 200)
-		self.assertIn('Access denied'.encode(), r.data)
+		self.assertEqual(r.status_code, 403)
 
 	def test_index_signupperm(self):
 		current_app.config['ACL_SIGNUP_GROUP'] = 'uffd_access'
@@ -345,7 +344,6 @@ class TestInviteAdminViews(UffdTestCase):
 		self.login_as('user')
 		r = self.client.get(path=url_for('invite.index'), follow_redirects=True)
 		self.assertEqual(r.status_code, 200)
-		self.assertNotIn('Access denied'.encode(), r.data)
 		self.assertNotIn(token1.encode(), r.data)
 		self.assertIn(token2.encode(), r.data)
 		self.assertNotIn(token3.encode(), r.data)
@@ -362,7 +360,6 @@ class TestInviteAdminViews(UffdTestCase):
 		self.login_as('user')
 		r = self.client.get(path=url_for('invite.index'), follow_redirects=True)
 		self.assertEqual(r.status_code, 200)
-		self.assertNotIn('Access denied'.encode(), r.data)
 		self.assertNotIn('testrole1'.encode(), r.data)
 		self.assertIn('testrole2'.encode(), r.data)
 
diff --git a/tests/test_role.py b/tests/test_role.py
index 6fef9547..0adbde99 100644
--- a/tests/test_role.py
+++ b/tests/test_role.py
@@ -251,6 +251,7 @@ class TestRoleViews(UffdTestCase):
 		ldap.session.commit()
 		role = Role(name='test')
 		db.session.add(role)
+		role.groups[self.get_admin_group()] = RoleGroup()
 		user1 = self.get_user()
 		user2 = self.get_admin()
 		service_user = User.query.get('uid=service,{}'.format(self.app.config['LDAP_USER_SEARCH_BASE']))
@@ -281,26 +282,31 @@ class TestRoleViews(UffdTestCase):
 			ldap.session.delete(user)
 		ldap.session.add(User(loginname='service', is_service_user=True, mail='service@example.com', displayname='Service'))
 		ldap.session.commit()
+		admin_role = Role(name='admin', is_default=True)
+		db.session.add(admin_role)
+		admin_role.groups[self.get_admin_group()] = RoleGroup()
 		role = Role(name='test', is_default=True)
 		db.session.add(role)
 		user1 = self.get_user()
 		user2 = self.get_admin()
 		service_user = User.query.get('uid=service,{}'.format(self.app.config['LDAP_USER_SEARCH_BASE']))
 		role.members.add(service_user)
-		self.assertSetEqual(set(user1.roles_effective), {role})
-		self.assertSetEqual(set(user2.roles_effective), {role})
+		self.assertSetEqual(set(user1.roles_effective), {role, admin_role})
+		self.assertSetEqual(set(user2.roles_effective), {role, admin_role})
 		self.assertSetEqual(set(service_user.roles_effective), {role})
 		db.session.commit()
 		role_id = role.id
+		admin_role_id = admin_role.id
 		self.assertSetEqual(set(role.members), {service_user})
 		r = self.client.get(path=url_for('role.unset_default', roleid=role.id), follow_redirects=True)
 		dump('role_unset_default', r)
 		self.assertEqual(r.status_code, 200)
 		role = Role.query.get(role_id)
+		admin_role = Role.query.get(admin_role_id)
 		service_user = User.query.get('uid=service,{}'.format(self.app.config['LDAP_USER_SEARCH_BASE']))
 		self.assertSetEqual(set(role.members), {service_user})
-		self.assertSetEqual(set(user1.roles_effective), set())
-		self.assertSetEqual(set(user2.roles_effective), set())
+		self.assertSetEqual(set(user1.roles_effective), {admin_role})
+		self.assertSetEqual(set(user2.roles_effective), {admin_role})
 		ldap.session.delete(service_user)
 		ldap.session.commit()
 
diff --git a/tests/test_rolemod.py b/tests/test_rolemod.py
index d94fce37..81a39c97 100644
--- a/tests/test_rolemod.py
+++ b/tests/test_rolemod.py
@@ -36,8 +36,7 @@ class TestRolemodViews(UffdTestCase):
 		db.session.commit()
 		r = self.client.get(path=url_for('rolemod.index'), follow_redirects=True)
 		dump('rolemod_acl_notmod', r)
-		self.assertEqual(r.status_code, 200)
-		self.assertIn('Access denied'.encode(), r.data)
+		self.assertEqual(r.status_code, 403)
 
 	def test_show(self):
 		role = Role(name='test', moderator_group=self.get_access_group())
@@ -64,7 +63,7 @@ class TestRolemodViews(UffdTestCase):
 		db.session.commit()
 		r = self.client.get(path=url_for('rolemod.show', role_id=role.id), follow_redirects=True)
 		dump('rolemod_show_noperm', r)
-		self.assertIn('Access denied'.encode(), r.data)
+		self.assertEqual(r.status_code, 403)
 
 	def test_show_nomod(self):
 		# Make sure we pass the blueprint-wide acl check
@@ -74,7 +73,7 @@ class TestRolemodViews(UffdTestCase):
 		db.session.commit()
 		r = self.client.get(path=url_for('rolemod.show', role_id=role.id), follow_redirects=True)
 		dump('rolemod_show_nomod', r)
-		self.assertIn('Access denied'.encode(), r.data)
+		self.assertEqual(r.status_code, 403)
 
 	def test_update(self):
 		role = Role(name='test', description='old_description', moderator_group=self.get_access_group())
@@ -102,7 +101,7 @@ class TestRolemodViews(UffdTestCase):
 		db.session.commit()
 		r = self.client.post(path=url_for('rolemod.update', role_id=role.id), data={'description': 'new_description'}, follow_redirects=True)
 		dump('rolemod_update_noperm', r)
-		self.assertIn('Access denied'.encode(), r.data)
+		self.assertEqual(r.status_code, 403)
 		self.assertEqual(Role.query.get(role.id).description, 'old_description')
 
 	def test_update_nomod(self):
@@ -113,7 +112,7 @@ class TestRolemodViews(UffdTestCase):
 		db.session.commit()
 		r = self.client.post(path=url_for('rolemod.update', role_id=role.id), data={'description': 'new_description'}, follow_redirects=True)
 		dump('rolemod_update_nomod', r)
-		self.assertIn('Access denied'.encode(), r.data)
+		self.assertEqual(r.status_code, 403)
 		self.assertEqual(Role.query.get(role.id).description, 'old_description')
 
 	def test_delete_member(self):
@@ -160,7 +159,7 @@ class TestRolemodViews(UffdTestCase):
 		self.assertTrue(user in role.members)
 		r = self.client.get(path=url_for('rolemod.delete_member', role_id=role.id, member_dn=user.dn), follow_redirects=True)
 		dump('rolemod_delete_member_noperm', r)
-		self.assertIn('Access denied'.encode(), r.data)
+		self.assertEqual(r.status_code, 403)
 		user_updated = self.get_admin()
 		role = Role.query.get(role.id)
 		self.assertTrue(user_updated in role.members)
@@ -177,7 +176,7 @@ class TestRolemodViews(UffdTestCase):
 		self.assertTrue(user in role.members)
 		r = self.client.get(path=url_for('rolemod.delete_member', role_id=role.id, member_dn=user.dn), follow_redirects=True)
 		dump('rolemod_delete_member_nomod', r)
-		self.assertIn('Access denied'.encode(), r.data)
+		self.assertEqual(r.status_code, 403)
 		user_updated = self.get_admin()
 		role = Role.query.get(role.id)
 		self.assertTrue(user_updated in role.members)
diff --git a/uffd/__init__.py b/uffd/__init__.py
index 1a958569..90feeafd 100644
--- a/uffd/__init__.py
+++ b/uffd/__init__.py
@@ -2,12 +2,12 @@ import os
 import secrets
 import sys
 
-from flask import Flask, redirect, url_for, request
+from flask import Flask, redirect, url_for, request, render_template
 from flask_babel import Babel
 from werkzeug.routing import IntegerConverter
 from werkzeug.serving import make_ssl_devcert
 from werkzeug.contrib.profiler import ProfilerMiddleware
-from werkzeug.exceptions import InternalServerError
+from werkzeug.exceptions import InternalServerError, Forbidden
 from flask_migrate import Migrate
 
 import uffd.ldap
@@ -94,6 +94,10 @@ def create_app(test_config=None): # pylint: disable=too-many-locals
 		app.test_request_context().push() # LDAP ORM requires request context
 		return {'db': db, 'ldap': uffd.ldap.ldap, 'User': User, 'Group': Group, 'Role': Role, 'Mail': Mail}
 
+	@app.errorhandler(403)
+	def handle_403(error):
+		return render_template('403.html', description=error.description if error.description != Forbidden.description else None), 403
+
 	@app.route("/")
 	def index(): #pylint: disable=unused-variable
 		return redirect(url_for('selfservice.index'))
diff --git a/uffd/invite/views.py b/uffd/invite/views.py
index d93dd892..65f4c51c 100644
--- a/uffd/invite/views.py
+++ b/uffd/invite/views.py
@@ -1,7 +1,7 @@
 import datetime
 import functools
 
-from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify
+from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, abort
 from flask_babel import gettext as _, lazy_gettext
 import sqlalchemy
 
@@ -35,8 +35,7 @@ def invite_acl_required(func):
 	@login_required()
 	def decorator(*args, **kwargs):
 		if not invite_acl():
-			flash('Access denied')
-			return redirect(url_for('index'))
+			abort(403)
 		return func(*args, **kwargs)
 	return decorator
 
diff --git a/uffd/mail/views.py b/uffd/mail/views.py
index 9cc7e585..2e11905b 100644
--- a/uffd/mail/views.py
+++ b/uffd/mail/views.py
@@ -1,4 +1,4 @@
-from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app
+from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app, abort
 from flask_babel import gettext as _, lazy_gettext
 
 from uffd.navbar import register_navbar
@@ -11,10 +11,9 @@ from uffd.mail.models import Mail
 bp = Blueprint("mail", __name__, template_folder='templates', url_prefix='/mail/')
 @bp.before_request
 @login_required()
-def mail_acl(): #pylint: disable=inconsistent-return-statements
+def mail_acl():
 	if not mail_acl_check():
-		flash('Access denied')
-		return redirect(url_for('index'))
+		abort(403)
 
 def mail_acl_check():
 	return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP'])
diff --git a/uffd/mfa/views.py b/uffd/mfa/views.py
index 287b4ab0..69b5cc94 100644
--- a/uffd/mfa/views.py
+++ b/uffd/mfa/views.py
@@ -44,8 +44,7 @@ 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 request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP']):
-		flash('Access denied')
-		return redirect(url_for('index'))
+		abort(403)
 	user = User.query.filter_by(uid=uid).one()
 	MFAMethod.query.filter_by(dn=user.dn).delete()
 	db.session.commit()
diff --git a/uffd/oauth2/views.py b/uffd/oauth2/views.py
index 898d3d9b..e3300929 100644
--- a/uffd/oauth2/views.py
+++ b/uffd/oauth2/views.py
@@ -122,17 +122,11 @@ validator = UffdRequestValidator()
 server = oauthlib.oauth2.WebApplicationServer(validator)
 bp = Blueprint('oauth2', __name__, url_prefix='/oauth2/', template_folder='templates')
 
-def display_oauth_errors(func):
-	@functools.wraps(func)
-	def decorator(*args, **kwargs):
-		try:
-			return func(*args, **kwargs)
-		except oauthlib.oauth2.rfc6749.errors.OAuth2Error as ex:
-			return render_template('oauth2/error.html', error=type(ex).__name__, error_description=ex.description), 400
-	return decorator
+@bp.errorhandler(oauthlib.oauth2.rfc6749.errors.OAuth2Error)
+def handle_oauth2error(error):
+	return render_template('oauth2/error.html', error=type(error).__name__, error_description=error.description), 400
 
 @bp.route('/authorize', methods=['GET', 'POST'])
-@display_oauth_errors
 def authorize():
 	scopes, credentials = server.validate_authorization_request(request.url, request.method, request.form, request.headers)
 	client = OAuth2Client.from_id(credentials['client_id'])
@@ -177,7 +171,7 @@ def authorize():
 	# 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']):
-		raise oauthlib.oauth2.rfc6749.errors.AccessDeniedError('User is not permitted to authenticate with this service.')
+		abort(403, description=_("You don't have the permission to access the service <b>%(service_name)s</b>.", service_name=client.client_id))
 	session['oauth2-clients'] = session.get('oauth2-clients', [])
 	if client.client_id not in session['oauth2-clients']:
 		session['oauth2-clients'].append(client.client_id)
diff --git a/uffd/role/views.py b/uffd/role/views.py
index 4c10a8b5..0b3e827f 100644
--- a/uffd/role/views.py
+++ b/uffd/role/views.py
@@ -1,6 +1,6 @@
 import sys
 
-from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app
+from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app, abort
 from flask_babel import gettext as _, lazy_gettext
 import click
 
@@ -39,10 +39,9 @@ def add_cli_commands(state):
 
 @bp.before_request
 @login_required()
-def role_acl(): #pylint: disable=inconsistent-return-statements
+def role_acl():
 	if not role_acl_check():
-		flash(_('Access denied'))
-		return redirect(url_for('index'))
+		abort(403)
 
 def role_acl_check():
 	return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP'])
diff --git a/uffd/rolemod/views.py b/uffd/rolemod/views.py
index be60ee34..58849101 100644
--- a/uffd/rolemod/views.py
+++ b/uffd/rolemod/views.py
@@ -1,4 +1,4 @@
-from flask import Blueprint, render_template, request, url_for, redirect, flash
+from flask import Blueprint, render_template, request, url_for, redirect, flash, abort
 from flask_babel import gettext as _, lazy_gettext
 
 from uffd.navbar import register_navbar
@@ -16,10 +16,9 @@ def user_is_rolemod():
 
 @bp.before_request
 @login_required()
-def acl_check(): #pylint: disable=inconsistent-return-statements
+def acl_check():
 	if not user_is_rolemod():
-		flash('Access denied')
-		return redirect(url_for('index'))
+		abort(403)
 
 @bp.route("/")
 @register_navbar(12, lazy_gettext('Moderation'), icon='user-lock', blueprint=bp, visible=user_is_rolemod)
@@ -33,8 +32,7 @@ def show(role_id):
 	User.query.all()
 	role = Role.query.get_or_404(role_id)
 	if role.moderator_group not in request.user.groups:
-		flash(_('Access denied'))
-		return redirect(url_for('index'))
+		abort(403)
 	return render_template('rolemod/show.html', role=role)
 
 @bp.route("/<int:role_id>", methods=['POST'])
@@ -42,8 +40,7 @@ def show(role_id):
 def update(role_id):
 	role = Role.query.get_or_404(role_id)
 	if role.moderator_group not in request.user.groups:
-		flash(_('Access denied'))
-		return redirect(url_for('index'))
+		abort(403)
 	if request.form['description'] != role.description:
 		if len(request.form['description']) > 256:
 			flash(_('Description too long'))
@@ -57,8 +54,7 @@ def update(role_id):
 def delete_member(role_id, member_dn):
 	role = Role.query.get_or_404(role_id)
 	if role.moderator_group not in request.user.groups:
-		flash(_('Access denied'))
-		return redirect(url_for('index'))
+		abort(403)
 	member = User.query.get_or_404(member_dn)
 	role.members.discard(member)
 	member.update_groups()
diff --git a/uffd/session/views.py b/uffd/session/views.py
index c674648b..5e8433b0 100644
--- a/uffd/session/views.py
+++ b/uffd/session/views.py
@@ -129,8 +129,7 @@ def login_required(group=None):
 			if not request.user:
 				return redirect(url_for('mfa.auth', ref=request.full_path))
 			if not request.user.is_in_group(group):
-				flash(_('Access denied'))
-				return redirect(url_for('index'))
+				abort(403)
 			return func(*args, **kwargs)
 		return decorator
 	return wrapper
diff --git a/uffd/templates/403.html b/uffd/templates/403.html
new file mode 100644
index 00000000..5a19dc98
--- /dev/null
+++ b/uffd/templates/403.html
@@ -0,0 +1,23 @@
+{% extends 'base.html' %}
+
+{% block body %}
+<div class="row mt-2 justify-content-center">
+	<div class="col-lg-6 col-md-10" style="background: #f7f7f7; box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3); padding: 30px;">
+		<div class="text-center">
+			<img alt="branding logo" src="{{ config.get("BRANDING_LOGO_URL") }}" class="col-lg-8 col-md-12" >
+		</div>
+		<div class="col-12">
+			<h2 class="text-center">{{ _('Access Denied') }}</h2>
+		</div>
+		<div class="form-group col-12">
+			<p>
+				{% if description %}
+				{{ description|safe }}
+				{% else %}
+				{{ _("You don't have the permission to access this page.") }}
+				{% endif %}
+			</p>
+		</div>
+	</div>
+</div>
+{% endblock %}
diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo
index 8732d8a30f74045b143df675da5ec4687b98356f..89920cdc366a7daa31d3fb1afc9fceef4be3562d 100644
GIT binary patch
delta 5196
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
Bml*&6

delta 4936
zcmeDD%J}3HWBolLmZ=O33=DdV3=A?13=BP-ARYq$5n^EAXJBCXE5yJc%)r3FE6l*a
z#=yX!D9pg%!@$6xA<V$Q!@$5$D$KyZ#lXPO1m*Wa`Ll!>7<d^N7?uh%Ft9T)Fl-iP
zU=U$oVAw6pz@W&$P|t8dn1R8bfq~(ZFav`j0|SGR2m?bQ0|P^f2t?yG5e9~61_p*F
zA`A@685kHGL>U;|85kH|h%zwLGB7Y0h%qoIF)%Q!6Jua-V_;yoD8|4b$-uxMA<n=c
z1~N|^;sH-_1_lcT28Kv+h(jldgFI5tz%UogU|?7RRk&K5fx!XfLU9HLRt5%!KTw)U
z0^&dp35bDK5)2Ho3=9lj5)2HY3=9kz5)2H|3=9l)5)2H23=9mjBp4Vt7#JAVNI)F2
zRRR)%XCxRH_!t-%9!M}SaMv?1Fua$5xac=j1DhnsWeg1bk`RN%Bq2epBgw$P&A`B5
zDG3QeSEzUtlunm~SWqI#z+lP1z|a9T=MYr>q$D`V8Lmk}9QH$!fuSB0cZ^aHi}<7%
z7}OaU7*wPf7^D~&7<{A{7*ZG*7}BBgXQUu5e*~pJN-;1ffRc(d1A{dvO-MtcGFF;_
zA(MfDVXibJ&e>%k7V^tLJSHK-z#zcDz#uQfP!CRA1~L$zImj?DFoP_XfdplM3<HB4
z0|P^j48($N8AzHq4mIei48%cqWEdE(GB7Z_mSJG1U|?W4D9gYwk%581N)BSq899i>
zFXR{)ltCdb2Z=&q`Fe-}%JL8aBY8+-a*}6Y*v!DdkO~!-RDk$IQvs5=3>6p{#2FYE
zycHlpo~!`zQ8|>~p#aHEa}*#J?^J+z@Bmc)i~_{o2lWb&IC-xC2|7VVh(&>l5RH+F
z3=G-~3=9d1ki^)d2ywt9MFs{IP~uW#U;yRVjf#+{*saLGpvu6&a7Pgma!g7L3@)Ik
zQG!Hmni3=g`jjB%)o)UQB)%O`1&5U&2E0~cV322EU=UP>SZJus0M7T`$_xw|3=9lG
z$`A{yl_3t9qYO!;3zZ=uxlS39ruHc_Fz7NcFkFGEXHtRqTv-L|(Rv0W6^H|@R3I8%
zq5M!4NRTC{Kzx{^!oZ-<z`#(Z0<mzJ3IjtZ0|UcqsCq_KNZR31g*Ze2O3SK3qDD)V
zfkB9Yfx%i85(0jzAam;(7>ZOOKCMw@U{GOTV3?x{$zBIl85pK8Ffd$GWnkFIz`&5L
z21&)T>JVB@9b&M)IwTD^s53BVGB7ZNs6+C59h5&+9paJw>JW#WR)<9O4Rr>FbWr}6
z(10jt&;SPsLyrc;z$qG#pjx59z>v?tz_49|fuW6ofk9mpk_*;pLL9P36Jp^}sQ4XC
z1_oOO28LIfkSNp8f`qKT7Rbj84E9<Ob3L^f80taUD?*EbL5qQbp&Y7Ukru>)HBkO8
zEl6THq6H~fPH92P2NrEeT9DU<M2Uws#6n+f28IYwNN7VmwpN>gL4tvSVW&1E1TJYq
zLioNmLp`{N{GttU(O;+n9vudT84L^zk~$C{Zqk7`Xtxd|$WQ7(47jBOvGBbPBn^Gj
zfy6PRE+j-mbRm_Lwl1VxsL+Kt?4vFN!z57Atyd2*;I1B|Y=5H1z|hLTz#yj2z%T)n
z7W5&hU)KN<CGG|c42u~U7@`dzssFzL1A_qr1A~GgB<=$ZAt6+42&tsD8bb8{HH4@a
zH)3FLWME*hFoM)ERrN-Y)VSLS5`_1SAVK}a2$FqX88I-t1r@QzkjmzrF*KE%Kte*m
z1QK$JCJ>Jpm@qKpF)%Qgn=mkVFfcGIGhtxx0+o0skhEoG%D`a5z`)>S3W<vPdQ*sn
zJ*JSjnq&$okmi{}9CpAIQjk0`g;>CB2JsP>8HBH521(7@W(*8bpj=`GsjiPfX(e+=
zP}i9=Fr<RY6AK0gGf-NyU|@&=)&J`(7#Lg`7#O%MAtha)B?H4T1_p*&ONdWgtRVIJ
zW-A7U5(Wl_UsenZ#taM$`PLAhFSdpR=_YGP(f!4mfkBOdfkE5`Vy>MHq(E!2fz$=7
zY#10y85kIj*?@}hdIkm?TSzJnv4xaWwYCflk)VRbmVqIWfq{Y54nn8fK@2)+$G~s|
z6r^?x44|gd7JEqI^m2fNOoRg@B#Rs%7S=gHqIQ-8Br(r-fF#xx4h#%Tp!~nt0TMUc
zp!7ZmNPa!$z`)SU$iQ&T0g|dyoFIv?+zDb(w-Y!B8KyWv(!xh4h{a6K5C`!&LoAYU
zhUiy!hD5cMGbC*}I5RNRgYtctGsMS@&I}CB3=9mnof#P7K=rc=B%9T_Ffb%DFfdGZ
zVPIIpz`*d`1rjAoTp1XOK-I1*q-c(FV_*noU|{HTgZTWB8^of2ZV(5ExHB-Af(kHq
zh{Iyt8S25cSB5(z1d7}tKB;ntSkUbbNu`&d8t%J865A_xNKvij0f}>eC|%?MDVU~u
zK$>71JRphmkOw40PD16cdq6z)$pc#dvwK1klb9!@h*b521hKv+q#n2Mgcum=32{)m
zC&YjfD8Ca*PxFNMe6c4a|L^yNc#Pc(qMzRjQeG%}L82<C9?FRGf|OWUUJML+3=9m7
zUXc90!3$Ceo%4cJQpw(sG|=n~aqx6+1_m8aKKF*?g3I2JC=m65w2m`<AVK}y2jcKg
zJ`e}}_koyG&+ZF}8);uiqSNz*6fkzakZk1S3&|Ckz6=Z@3=9m@eIY)1;|nq9pD!fJ
z*!&n6UV*Zm9|J=r0|Nt-KLf*R1_p*2e@Mxy5diUVQ~)IZrv-rRt!J1U05Nbw03_%S
z1we|*GXV??hZq<b-UdJ%wjmHAe;^PNGN%F|iR(omB+mZ@f(>Ml41%<Dbb=s39TEfy
zsa7aGB?zK^aS$j3>KPdJ1u-xfF)%P(3SwXg0Oj{!h=pOn5ErKfL#orFV2A@Y1Va+v
zu3$*Mz8?%pD^G$UiScbP!~p^!kX&IA0?ADoAq)(D3=9m%Lm(a$3uR#70Of!4P>4ki
zp^)t16ADQKO`(vu-53h-@jZ|RP=^F6&l(1?z$6Uf!xSi890o~*O<|D4J1>lZ!3xx&
z34=uCw=hVEtAsN!)PwqLX5o-T;usE*@Ck<`wzzPJOY6cxLB+r@Kb!&F&fgpk@%j63
zNCEUE91`?_5s<W#5&>~gMg*kZFOPu8Plx~&xeN@8BOoR2?g)l@aOd$!1SCj5MnGIB
z7757>E|CljD?!z5Bm=_%P}D?09C|DY666=7AO_!xf~bER1qnHpXh`BUj)qjTQPB_w
z&5njd?W$-<?YJwt9#W|=$3RkXMGU0C=!Eho#6VoUFouDl2h@UzVPIItz`)=Z3kj*O
zv5;)V9tTNOB5@Fl^y3&9oER7w?BXD4rZEmuUUbJn^eu>kICxWi9HiDe83(DCzs5m)
zXcP}g6SnaXzGpndVIlF5D2R@S#O?HW1_l!b28J#15Q|<y>Cf>D47s3gJJg))1W4so
zo&X7fxe1V{sb8A_$;ZbMAVGN!s^DP)1H&o?28NFbkfL)=BE$!)5+NG*Cqinw8;Ou?
z_$d+MGma#PMN&zSD0N7Jq><Dl28LM-3=D^oAO%xwGPsFX&u}{#;<J(zNNTT6VPKfa
zz`(E~1=4VEN`+*rtErHn{gDbO7ueGvjn-3X4B#Hl*EC2I%qksHiKV17Fa&|jNr&W~
z-{}zZB{LWpyg^Zz!NB0jP|v_HDFc%K?_@wyFMlQ^bqi-gd?t~}z#ziNz@V22Nwv;d
z5Oq0O5Q7V{AnN+EAZcMq76U^z0|UdxECvQU1_lP%Y>-6^3^Cb|s9cuKzz_(M&t_l{
z0p)+@97t-G$brPUVh+S7x;c>49G(No&uuvn3l`--vgP_5h=CV!AeGJS97uJnoD1oG
zPs@e0dO;1s4Gatnm3feYNGl&wyLRR?Fw|EuFfd%mhq%bQ01~8g3m6zaF)%QkD1bDT
zb{9h0c7{a^3>}~`q9RD56D@|sxmPiyT8=4(B+5C(5D)AuhB)kTF(g}Smq0=+vIG*v
z<s}UD;2{&iQb_HkTME&bUkdTb%u+~Y!czv}x0OLG_*url@EO!hFNbu~xhfzI*;fI{
zh8HRzA@#Zf(s=z*0f|!aN=OLHKxvIi1&D-6B_s+gD<QeTsS=X9y(%Y532$Od*?d-b
ds>bF=Hh)=|3@tW4bX;mQIk;4DvrcI&D*)YOM+E=?

diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po
index 89805c8f..70e5f135 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: 2021-09-04 21:18+0200\n"
+"POT-Creation-Date: 2021-09-04 21:53+0200\n"
 "PO-Revision-Date: 2021-05-25 21:18+0200\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language: de\n"
@@ -44,54 +44,54 @@ msgstr "eine Stunde"
 msgid "%(hours)d hours"
 msgstr "%(hours)d Stunden\""
 
-#: uffd/invite/views.py:56
+#: uffd/invite/views.py:55
 msgid "Invites"
 msgstr "Einladungslinks"
 
-#: uffd/invite/views.py:85
+#: uffd/invite/views.py:84
 msgid "The \"Expires After\" date is too far in the future"
 msgstr "Das Ablaufdatum liegt zu weit in der Zukunft"
 
-#: uffd/invite/views.py:88
+#: uffd/invite/views.py:87
 msgid "You are not allowed to create invite links with these permissions"
 msgstr "Dir fehlen Berechtigungen um diesen Einladungslink zu erstellen"
 
-#: uffd/invite/views.py:91
+#: uffd/invite/views.py:90
 msgid "Invite link must either allow signup or grant at least one role"
 msgstr ""
 "Einladungslink muss entweder Account-Registrierung erlauben oder Rollen "
 "vergeben"
 
-#: uffd/invite/views.py:119 uffd/invite/views.py:148
+#: uffd/invite/views.py:118 uffd/invite/views.py:147
 msgid "Invalid invite link"
 msgstr "Ungültiger Einladungslink"
 
-#: uffd/invite/views.py:136
+#: uffd/invite/views.py:135
 msgid "Roles successfully updated"
 msgstr "Rollen erfolgreich geändert"
 
-#: uffd/invite/views.py:151
+#: uffd/invite/views.py:150
 msgid "Invite link does not allow signup"
 msgstr "Einladungslink erlaubt keine Account-Registrierung"
 
-#: uffd/invite/views.py:173 uffd/selfservice/views.py:50
+#: uffd/invite/views.py:172 uffd/selfservice/views.py:50
 #: uffd/signup/views.py:49
 msgid "Passwords do not match"
 msgstr "Die Passwörter stimmen nicht überein"
 
-#: uffd/invite/views.py:178 uffd/signup/views.py:54
+#: uffd/invite/views.py:177 uffd/signup/views.py:54
 #, python-format
 msgid "Too many signup requests with this mail address! Please wait %(delay)s."
 msgstr ""
 "Zu viele Account-Registrierungen mit dieser E-Mail-Adresse! Bitte warte "
 "%(delay)s."
 
-#: uffd/invite/views.py:180 uffd/signup/views.py:56 uffd/signup/views.py:92
+#: uffd/invite/views.py:179 uffd/signup/views.py:56 uffd/signup/views.py:92
 #, python-format
 msgid "Too many requests! Please wait %(delay)s."
 msgstr "Zu viele Anfragen! Bitte warte %(delay)s."
 
-#: uffd/invite/views.py:193 uffd/signup/views.py:68
+#: uffd/invite/views.py:192 uffd/signup/views.py:68
 msgid "Cound not send mail"
 msgstr "Mailversand fehlgeschlagen"
 
@@ -346,15 +346,15 @@ msgstr "Neuen Account registrieren"
 msgid "Login and add the roles to your account"
 msgstr "Anmelden und die Rollen zu deinem Account hinzufügen"
 
-#: uffd/mail/views.py:23
+#: uffd/mail/views.py:22
 msgid "Forwardings"
 msgstr "Weiterleitungen"
 
-#: uffd/mail/views.py:47
+#: uffd/mail/views.py:46
 msgid "Mail mapping updated."
 msgstr "Mailweiterleitung geändert."
 
-#: uffd/mail/views.py:56
+#: uffd/mail/views.py:55
 msgid "Deleted mail mapping."
 msgstr "Mailweiterleitung gelöscht."
 
@@ -383,19 +383,19 @@ msgstr "Speichern"
 msgid "Delete"
 msgstr "Löschen"
 
-#: uffd/mfa/views.py:54
+#: uffd/mfa/views.py:53
 msgid "Two-factor authentication was reset"
 msgstr "Zwei-Faktor-Authentifizierung wurde zurückgesetzt"
 
-#: uffd/mfa/views.py:83
+#: uffd/mfa/views.py:82
 msgid "Generate recovery codes first!"
 msgstr "Generiere zuerst die Wiederherstellungscodes!"
 
-#: uffd/mfa/views.py:92
+#: uffd/mfa/views.py:91
 msgid "Code is invalid"
 msgstr "Wiederherstellungscode ist ungültig"
 
-#: uffd/mfa/views.py:116
+#: uffd/mfa/views.py:115
 #, python-format
 msgid ""
 "2FA WebAuthn support disabled because import of the fido2 module failed "
@@ -404,16 +404,16 @@ msgstr ""
 "2FA WebAuthn Unterstützung deaktiviert, da das fido2 Modul nicht geladen "
 "werden konnte (%s)"
 
-#: uffd/mfa/views.py:225
+#: uffd/mfa/views.py:224
 #, python-format
 msgid "We received too many invalid attempts! Please wait at least %s."
 msgstr "Wir haben zu viele fehlgeschlagene Versuche! Bitte warte mindestens %s."
 
-#: uffd/mfa/views.py:239
+#: uffd/mfa/views.py:238
 msgid "You have exhausted your recovery codes. Please generate new ones now!"
 msgstr "Du hast keine Wiederherstellungscode mehr. Bitte generiere diese jetzt!"
 
-#: uffd/mfa/views.py:242
+#: uffd/mfa/views.py:241
 msgid ""
 "You only have a few recovery codes remaining. Make sure to generate new "
 "ones before they run out."
@@ -421,7 +421,7 @@ msgstr ""
 "Du hast nur noch wenige Wiederherstellungscodes übrig. Bitte generiere "
 "diese erneut bevor keine mehr übrig sind."
 
-#: uffd/mfa/views.py:246
+#: uffd/mfa/views.py:245
 msgid "Two-factor authentication failed"
 msgstr "Zwei-Faktor-Authentifizierung fehlgeschlagen"
 
@@ -704,7 +704,7 @@ msgstr "Verifiziere und beende das Setup"
 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:146 uffd/selfservice/views.py:76
+#: uffd/oauth2/views.py:140 uffd/selfservice/views.py:76
 #: uffd/session/views.py:95
 #, python-format
 msgid ""
@@ -714,14 +714,23 @@ msgstr ""
 "Wir haben zu viele Anfragen von deiner IP-Adresses bzw. aus deinem "
 "Netzwerk empfangen! Bitte warte mindestens %(delay)s."
 
-#: uffd/oauth2/views.py:154
+#: uffd/oauth2/views.py:148
 msgid "Device login is currently not available. Try again later!"
 msgstr "Geräte-Login ist gerade nicht verfügbar. Versuche es später nochmal!"
 
-#: uffd/oauth2/views.py:167
+#: uffd/oauth2/views.py:161
 msgid "Device login failed"
 msgstr "Gerätelogin fehlgeschlagen"
 
+#: uffd/oauth2/views.py:174
+#, python-format
+msgid ""
+"You don't have the permission to access the service "
+"<b>%(service_name)s</b>."
+msgstr ""
+"Du bist nicht berechtigt, auf den Dienst <b>%(service_name)s</b> "
+"zuzugreifen."
+
 #: uffd/oauth2/templates/oauth2/logout.html:10 uffd/templates/base.html:99
 msgid "Logout"
 msgstr "Abmelden"
@@ -764,19 +773,13 @@ msgstr ""
 "Automatisches Abmelden bei einigen Diensten fehlgeschlagen. Nochmal "
 "versuchen?"
 
-#: uffd/role/views.py:44 uffd/rolemod/views.py:36 uffd/rolemod/views.py:45
-#: uffd/rolemod/views.py:60 uffd/session/views.py:132
-#: uffd/user/views_group.py:14 uffd/user/views_user.py:25
-msgid "Access denied"
-msgstr "Zugriff verweigert"
-
-#: uffd/role/views.py:51 uffd/selfservice/templates/selfservice/self.html:86
+#: uffd/role/views.py:50 uffd/selfservice/templates/selfservice/self.html:86
 #: uffd/user/templates/user/list.html:20 uffd/user/templates/user/show.html:21
 #: uffd/user/templates/user/show.html:90
 msgid "Roles"
 msgstr "Rollen"
 
-#: uffd/role/views.py:101
+#: uffd/role/views.py:100
 msgid "Locked roles cannot be deleted"
 msgstr "Gesperrte Rollen können nicht gelöscht werden"
 
@@ -868,15 +871,15 @@ msgstr "derzeit enthaltene Gruppen"
 msgid "2FA required"
 msgstr "2FA erforderlich"
 
-#: uffd/rolemod/views.py:25
+#: uffd/rolemod/views.py:24
 msgid "Moderation"
 msgstr "Moderation"
 
-#: uffd/rolemod/views.py:49
+#: uffd/rolemod/views.py:46
 msgid "Description too long"
 msgstr "Beschreibung zu lang"
 
-#: uffd/rolemod/views.py:67
+#: uffd/rolemod/views.py:63
 msgid "Member removed"
 msgstr "Mitglied entfernt"
 
@@ -1192,15 +1195,15 @@ msgstr "Du hast keinen Zugriff auf diesen Service"
 msgid "You need to login first"
 msgstr "Du musst dich erst anmelden"
 
-#: uffd/session/views.py:149 uffd/session/views.py:159
+#: uffd/session/views.py:148 uffd/session/views.py:158
 msgid "Initiation code is no longer valid"
 msgstr "Startcode ist nicht mehr gültig"
 
-#: uffd/session/views.py:163
+#: uffd/session/views.py:162
 msgid "Invalid confirmation code"
 msgstr "Ungültiger Bestätigungscode"
 
-#: uffd/session/views.py:175 uffd/session/views.py:186
+#: uffd/session/views.py:174 uffd/session/views.py:185
 msgid "Invalid initiation code"
 msgstr "Ungültiger Startcode"
 
@@ -1403,6 +1406,14 @@ msgstr ""
 " Gründen keine Bestätigungsmail erhalten hast, kannst du den Prozess "
 "einfach von Vorne beginnen."
 
+#: uffd/templates/403.html:10
+msgid "Access Denied"
+msgstr "Zugriff verweigert"
+
+#: uffd/templates/403.html:17
+msgid "You don't have the permission to access this page."
+msgstr "Du bist nicht berechtigt, auf diese Seite zuzugreifen."
+
 #: uffd/templates/base.html:84
 msgid "Change"
 msgstr "Ändern"
@@ -1422,45 +1433,45 @@ msgstr ""
 "und manche Symbole (<code>%(symbols)s</code>), keine Umlaute. Bitte "
 "verwende einen Passwort-Manager."
 
-#: uffd/user/views_group.py:21
+#: uffd/user/views_group.py:20
 msgid "Groups"
 msgstr "Gruppen"
 
-#: uffd/user/views_user.py:32
+#: uffd/user/views_user.py:31
 msgid "Users"
 msgstr "Accounts"
 
-#: uffd/user/views_user.py:52
+#: uffd/user/views_user.py:51
 msgid "Login name does not meet requirements"
 msgstr "Anmeldename entspricht nicht den Anforderungen"
 
-#: uffd/user/views_user.py:57
+#: uffd/user/views_user.py:56
 msgid "Mail is invalid"
 msgstr "E-Mail-Adresse nicht valide"
 
-#: uffd/user/views_user.py:61
+#: uffd/user/views_user.py:60
 msgid "Display name does not meet requirements"
 msgstr "Anzeigename entspricht nicht den Anforderungen"
 
-#: uffd/user/views_user.py:66
+#: uffd/user/views_user.py:65
 msgid "Password is invalid"
 msgstr "Passwort ist ungültig"
 
-#: uffd/user/views_user.py:80
+#: uffd/user/views_user.py:79
 msgid "Service user created"
 msgstr "Service-Account erstellt"
 
-#: uffd/user/views_user.py:83
+#: uffd/user/views_user.py:82
 msgid "User created. We sent the user a password reset link by mail"
 msgstr ""
 "Account erstellt. E-Mail mit einem Link zum Setzen des Passworts wurde "
 "versendet."
 
-#: uffd/user/views_user.py:85
+#: uffd/user/views_user.py:84
 msgid "User updated"
 msgstr "Account aktualisiert"
 
-#: uffd/user/views_user.py:96
+#: uffd/user/views_user.py:95
 msgid "Deleted user"
 msgstr "Account gelöscht"
 
diff --git a/uffd/user/views_group.py b/uffd/user/views_group.py
index 480a7b59..1526d102 100644
--- a/uffd/user/views_group.py
+++ b/uffd/user/views_group.py
@@ -1,5 +1,5 @@
-from flask import Blueprint, render_template, url_for, redirect, flash, current_app, request
-from flask_babel import gettext as _, lazy_gettext
+from flask import Blueprint, render_template, current_app, request, abort
+from flask_babel import lazy_gettext
 
 from uffd.navbar import register_navbar
 from uffd.session import login_required
@@ -9,10 +9,9 @@ from .models import Group
 bp = Blueprint("group", __name__, template_folder='templates', url_prefix='/group/')
 @bp.before_request
 @login_required()
-def group_acl(): #pylint: disable=inconsistent-return-statements
+def group_acl():
 	if not group_acl_check():
-		flash(_('Access denied'))
-		return redirect(url_for('index'))
+		abort(403)
 
 def group_acl_check():
 	return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP'])
diff --git a/uffd/user/views_user.py b/uffd/user/views_user.py
index 2b273946..2b4497dd 100644
--- a/uffd/user/views_user.py
+++ b/uffd/user/views_user.py
@@ -1,7 +1,7 @@
 import csv
 import io
 
-from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app
+from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app, abort
 from flask_babel import gettext as _, lazy_gettext
 
 from uffd.navbar import register_navbar
@@ -20,10 +20,9 @@ bp.add_app_template_global(User, 'User')
 
 @bp.before_request
 @login_required()
-def user_acl(): #pylint: disable=inconsistent-return-statements
+def user_acl():
 	if not user_acl_check():
-		flash(_('Access denied'))
-		return redirect(url_for('index'))
+		abort(403)
 
 def user_acl_check():
 	return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP'])
-- 
GitLab