diff --git a/src/backend/apps/authentication/adapters.py b/src/backend/apps/authentication/adapters.py index 88ec4e5e4..a8842f7c0 100644 --- a/src/backend/apps/authentication/adapters.py +++ b/src/backend/apps/authentication/adapters.py @@ -1,11 +1,16 @@ from django.conf import settings from allauth.account.adapter import DefaultAccountAdapter +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter class AccountAdapter(DefaultAccountAdapter): + ''' Governs accounts internally, for things like redirect urls. ''' def get_login_redirect_url(self, request): return settings.FRONTEND_BASE_URL def get_logout_redirect_url(self, request): - return settings.FRONTEND_BASE_URL \ No newline at end of file + if request.path.startswith('/drivebc-'): + return '/' + request.path.split('/')[1] + '/' + return settings.FRONTEND_BASE_URL + diff --git a/src/backend/apps/authentication/templates/admin/access_denied.html b/src/backend/apps/authentication/templates/admin/access_denied.html new file mode 100644 index 000000000..36df52afc --- /dev/null +++ b/src/backend/apps/authentication/templates/admin/access_denied.html @@ -0,0 +1,5 @@ +{% extends "admin/login.html" %} + +{% block content %} +
You do not have permission to view this page.
+{% endblock %} \ No newline at end of file diff --git a/src/backend/apps/authentication/templates/admin/access_requested.html b/src/backend/apps/authentication/templates/admin/access_requested.html new file mode 100644 index 000000000..e8f9d7503 --- /dev/null +++ b/src/backend/apps/authentication/templates/admin/access_requested.html @@ -0,0 +1,5 @@ +{% extends "admin/login.html" %} + +{% block content %} +An email has been sent to the admins. Please wait for them to contact you.
+{% endblock %} \ No newline at end of file diff --git a/src/backend/apps/authentication/templates/admin/idir_login.html b/src/backend/apps/authentication/templates/admin/idir_login.html new file mode 100644 index 000000000..79b31c949 --- /dev/null +++ b/src/backend/apps/authentication/templates/admin/idir_login.html @@ -0,0 +1,14 @@ +{% extends "admin/login.html" %} + +{% block branding %} +This part of the DriveBC website requires you to be authenticated with your +IDIR, using MFA
+ + + +{% endblock %} \ No newline at end of file diff --git a/src/backend/apps/authentication/templates/admin/request_access.html b/src/backend/apps/authentication/templates/admin/request_access.html new file mode 100644 index 000000000..9ea4e622d --- /dev/null +++ b/src/backend/apps/authentication/templates/admin/request_access.html @@ -0,0 +1,19 @@ +{% extends "admin/login.html" %} + +{% block branding %} +To access the DriveBC Admin site, you need to be granted access.
+ +Click the button below to trigger an access request to be sent to the admins +for your IDIR.
+ +IDIR user {{ name }} ({{ email }}) is requesting access to the DriveBC admin +interface. You can access their user record at the following link to toggle the +staff flag on their account:
+ + + +The system will NOT notify them of the change; you need to tell them yourself.
\ No newline at end of file diff --git a/src/backend/apps/authentication/templates/email/request_admin_access.txt b/src/backend/apps/authentication/templates/email/request_admin_access.txt new file mode 100644 index 000000000..0bfa7d976 --- /dev/null +++ b/src/backend/apps/authentication/templates/email/request_admin_access.txt @@ -0,0 +1,7 @@ +IDIR user {{ name }} ({{ email }}) is requesting access to the DriveBC admin +interface. You can access their user record at the following link to toggle the +staff flag on their account: + +{{ url }} + +The system will NOT notify them of the change; you need to tell them yourself. \ No newline at end of file diff --git a/src/backend/apps/authentication/views.py b/src/backend/apps/authentication/views.py index ce0420f3f..9ce33e33b 100644 --- a/src/backend/apps/authentication/views.py +++ b/src/backend/apps/authentication/views.py @@ -4,12 +4,13 @@ import environ from apps.webcam.models import Webcam +from django.conf import settings from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.core.exceptions import ObjectDoesNotExist from django.core.mail import EmailMultiAlternatives from django.db.utils import IntegrityError from django.http import HttpResponseRedirect -from django.shortcuts import reverse +from django.shortcuts import render, reverse from django.template.loader import render_to_string from django.utils.encoding import force_bytes, force_str from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode @@ -19,6 +20,8 @@ from rest_framework.response import Response from rest_framework.views import APIView +from apps.webcam.models import Webcam + from .models import DriveBCUser, FavouritedCameras, SavedRoutes from .serializers import FavouritedCamerasSerializer, SavedRoutesSerializer @@ -27,6 +30,38 @@ env = environ.Env() +def request_access(request): + return render(request, "admin/request_access.html") + + +def access_requested(request): + + if request.method == 'POST': + app = request.user._meta.app_label + model = request.user._meta.model_name + path = reverse('admin:%s_%s_change' % (app, model), args=[request.user.id]) + url = settings.FRONTEND_BASE_URL + path[1:] + first = request.user.first_name + last = request.user.last_name + name = f'{first} {last}' + context = {'name': name, 'email': request.user.email, 'url': url, } + + text = render_to_string('email/request_admin_access.txt', context) + html = render_to_string('email/request_admin_access.html', context) + + msg = EmailMultiAlternatives( + f'{name} requests access to DriveBC admin', + text, + settings.DRIVEBC_FEEDBACK_EMAIL_DEFAULT, + settings.ACCESS_REQUEST_RECEIVERS, + ) + msg.attach_alternative(html, 'text/html') + msg.send() + return HttpResponseRedirect(request.path) + + return render(request, "admin/access_requested.html") + + class FavouritedCamerasViewset(viewsets.ModelViewSet): queryset = FavouritedCameras.objects.all() serializer_class = FavouritedCamerasSerializer diff --git a/src/backend/apps/cms/templates/email/request_wagtail_access.html b/src/backend/apps/cms/templates/email/request_wagtail_access.html new file mode 100644 index 000000000..01e7ef3fa --- /dev/null +++ b/src/backend/apps/cms/templates/email/request_wagtail_access.html @@ -0,0 +1,7 @@ +IDIR user {{ name }} ({{ email }}) is requesting access to the Wagtail admin +interface. You can access their user record at the following link to add them +to the editors group:
+ + + +The system will NOT notify them of the change; you need to tell them yourself.
\ No newline at end of file diff --git a/src/backend/apps/cms/templates/email/request_wagtail_access.txt b/src/backend/apps/cms/templates/email/request_wagtail_access.txt new file mode 100644 index 000000000..4c42a9d45 --- /dev/null +++ b/src/backend/apps/cms/templates/email/request_wagtail_access.txt @@ -0,0 +1,7 @@ +IDIR user {{ name }} ({{ email }}) is requesting access to the Wagtail admin +interface. You can access their user record at the following link to add them +to the editors group: + +{{ url }} + +The system will NOT notify them of the change; you need to tell them yourself. \ No newline at end of file diff --git a/src/backend/apps/cms/templates/wagtailadmin/access_denied.html b/src/backend/apps/cms/templates/wagtailadmin/access_denied.html new file mode 100644 index 000000000..9ae5ecc4e --- /dev/null +++ b/src/backend/apps/cms/templates/wagtailadmin/access_denied.html @@ -0,0 +1,24 @@ +{% extends "admin/login.html" %} + +{% block branding %}+ You do not have permission to view this page because you're logged in with + an account that is not an IDIR MFA account. +
++ You can sign out of your current account by clicking the button below. + You'll be redirected to the login page for IDIR. +
+ + + {% else %} +You do not have permission to view this page.
+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/src/backend/apps/cms/templates/wagtailadmin/access_requested.html b/src/backend/apps/cms/templates/wagtailadmin/access_requested.html new file mode 100644 index 000000000..95fe5f8a0 --- /dev/null +++ b/src/backend/apps/cms/templates/wagtailadmin/access_requested.html @@ -0,0 +1,12 @@ +{% extends "wagtailadmin/base.html" %} + +{% block titletag %}Access Requested{% endblock %} + +{% block content %} + {% include "wagtailadmin/shared/header.html" with title="Access Requested" %} + +A message has been sent to the CMS admins. Please wait for them to + contact you.
+You can see this page because you're logged in with an IDIR, but you do + not have access to any functionality yet because you need to be added to the + editors group.
+ +Click the button below to send a message to CMS admins requesting access for your IDIR.
+ + +{% endblock %} \ No newline at end of file diff --git a/src/backend/apps/cms/urls.py b/src/backend/apps/cms/urls.py index 07192426f..6d193eff7 100644 --- a/src/backend/apps/cms/urls.py +++ b/src/backend/apps/cms/urls.py @@ -1,14 +1,23 @@ +from functools import wraps + +from allauth.account.decorators import secure_admin_login +from allauth.account.views import LogoutView as AllauthLogoutView +from django.conf import settings +from django.shortcuts import redirect from django.urls import include, path from rest_framework import routers from wagtail import urls as wagtail_urls from wagtail.admin import urls as wagtailadmin_urls +from wagtail.admin.auth import reject_request, permission_denied +from wagtail.admin.views.account import LoginView, LogoutView as WagtailLogoutView from wagtail.api.v2.router import WagtailAPIRouter from wagtail.api.v2.views import PagesAPIViewSet from wagtail.documents import urls as wagtaildocs_urls from wagtail.documents.api.v2.views import DocumentsAPIViewSet from wagtail.images.api.v2.views import ImagesAPIViewSet +from wagtail.utils.urlpatterns import decorate_urlpatterns -from .views import AdvisoryAPI, BulletinAPI +from .views import AdvisoryAPI, BulletinAPI, access_denied_idir wagtail_api_router = WagtailAPIRouter('wagtailapi') wagtail_api_router.register_endpoint('pages', PagesAPIViewSet) @@ -19,7 +28,34 @@ cms_api_router.register('advisories', AdvisoryAPI) cms_api_router.register('bulletins', BulletinAPI) + +def require_idir_auth(view_func): + def decorated_view(request, *args, **kwargs): + user = request.user + + if user.is_anonymous: + return reject_request(request) + + if user.username.endswith('azureidir'): + return view_func(request, *args, **kwargs) + + return redirect("cms_denied_idir") + + return decorated_view + +if settings.FORCE_IDIR_AUTHENTICATION: + login_view = secure_admin_login(LoginView.as_view()) + logout_view = AllauthLogoutView.as_view() + decorate_urlpatterns(wagtailadmin_urls.urlpatterns, require_idir_auth) +else: + login_view = LoginView.as_view() + logout_view = WagtailLogoutView.as_view() + + urlpatterns = [ + path("login/", login_view, name="cms_login"), + path("logout/", logout_view, name="cms_logout"), + path("denied", access_denied_idir, name="cms_denied_idir"), path('', include(wagtailadmin_urls)), path('documents', include(wagtaildocs_urls)), path('pages', include(wagtail_urls)), diff --git a/src/backend/apps/cms/views.py b/src/backend/apps/cms/views.py index 08435b05e..52d9e07ec 100644 --- a/src/backend/apps/cms/views.py +++ b/src/backend/apps/cms/views.py @@ -1,3 +1,11 @@ +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.http import HttpResponseRedirect +from django.shortcuts import render, reverse +from django.template.loader import render_to_string +from django.views.decorators.csrf import csrf_exempt +from rest_framework import viewsets + from apps.cms.models import Advisory, Bulletin from apps.cms.serializers import ( AdvisorySerializer, @@ -6,7 +14,6 @@ ) from apps.shared.enums import CacheKey, CacheTimeout from apps.shared.views import CachedListModelMixin -from rest_framework import viewsets class CMSViewSet(viewsets.ReadOnlyModelViewSet): @@ -36,3 +43,40 @@ class BulletinTestAPI(CachedListModelMixin, CMSViewSet): class BulletinAPI(BulletinTestAPI): serializer_class = BulletinSerializer + + +@csrf_exempt +def access_requested(request): + + if request.method == 'POST': + app = request.user._meta.app_label + model = request.user._meta.model_name + path = reverse('admin:%s_%s_change' % (app, model), args=[request.user.id]) + url = settings.FRONTEND_BASE_URL + path[1:] + first = request.user.first_name + last = request.user.last_name + name = f'{first} {last}' + context = {'name': name, + 'email': request.user.email, + 'url': url, } + + text = render_to_string('email/request_wagtail_access.txt', context) + html = render_to_string('email/request_wagtail_access.html', context) + + msg = EmailMultiAlternatives( + f'{name} requests access to Wagtail admin', + text, + settings.DRIVEBC_FEEDBACK_EMAIL_DEFAULT, + settings.ACCESS_REQUEST_RECEIVERS, + ) + msg.attach_alternative(html, 'text/html') + msg.send() + return HttpResponseRedirect(request.path) + + return render(request, 'wagtailadmin/access_requested.html') + + +def access_denied_idir(request): + return render(request, 'wagtailadmin/access_denied.html', context={ + "is_non_idir_login": True, + }) \ No newline at end of file diff --git a/src/backend/apps/cms/wagtail_hooks.py b/src/backend/apps/cms/wagtail_hooks.py index a18c4724d..b6e32a859 100644 --- a/src/backend/apps/cms/wagtail_hooks.py +++ b/src/backend/apps/cms/wagtail_hooks.py @@ -1,11 +1,17 @@ -from apps.cms.models import Advisory, Bulletin from django.contrib.auth.models import Permission from django.templatetags.static import static +from django.urls import path from django.utils.html import format_html +from django.utils.safestring import mark_safe + from wagtail import hooks from wagtail.admin.rich_text.editors.draftail.features import ControlFeature +from wagtail.admin.ui.components import Component from wagtail_modeladmin.options import ModelAdmin, modeladmin_register +from .models import Advisory, Bulletin +from .views import access_requested + @hooks.register("insert_global_admin_css") def insert_global_admin_css(): @@ -72,3 +78,35 @@ def register_readinglevel_feature(features): feature_name, ControlFeature({'type': feature_name}, js=['readinglevel.js']), ) + + +@hooks.register('construct_main_menu') +def hide_explorer_menu_items_without_right_groups(request, menu_items): + ''' + For a user who is not in the required groups, empty the menu. + + Currently the groups are Editors (i.e., authors) and Moderators (i.e., + approvers). A user who is merely in the IdirUser group should see nothing + at all in the admin interface, but model_admin entries still show up. + ''' + + if not request.user.groups.filter(name__in=['Moderators', 'Editors']): + menu_items[:] = [] + + +class RequestAccessPanel(Component): + order = 50 + template_name = 'wagtailadmin/request_access.html' + + +@hooks.register('construct_homepage_panels') +def add_access_request_panel(request, panels): + if not request.user.groups.filter(name__in=['Moderators', 'Editors']): + panels.append(RequestAccessPanel()) + + +@hooks.register('register_admin_urls') +def add_access_requested_url(): + return [ + path('access-requested', access_requested, name='cms-access-requested'), + ] \ No newline at end of file diff --git a/src/backend/apps/shared/admin.py b/src/backend/apps/shared/admin.py index 821ba2dcc..6483ca142 100644 --- a/src/backend/apps/shared/admin.py +++ b/src/backend/apps/shared/admin.py @@ -1,7 +1,8 @@ -from apps.shared.models import SiteSettings from django.contrib import admin from django.contrib.admin import ModelAdmin +from apps.shared.models import SiteSettings + class SiteSettingsAdmin(ModelAdmin): readonly_fields = ('id', ) diff --git a/src/backend/config/admin.py b/src/backend/config/admin.py new file mode 100644 index 000000000..4a01e67fe --- /dev/null +++ b/src/backend/config/admin.py @@ -0,0 +1,29 @@ +from django.conf import settings +from django.contrib.admin import AdminSite +from django.contrib.admin.apps import AdminConfig + + +class DriveBCAdminSite(AdminSite): + + site_header = "DriveBC Administration" + site_title = "DriveBC Administration" + index_title = "DriveBC Administration" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if settings.FORCE_IDIR_AUTHENTICATION: + self.login_template = 'admin/idir_login.html' + + def has_permission(self, request): + ''' Beyond normal admin_views permission, verify that user is IDIR ''' + + is_staff = super().has_permission(request) + + if settings.FORCE_IDIR_AUTHENTICATION: + return is_staff and request.user.username.endswith('azureidir') + + return is_staff + + +class DriveBCAdminConfig(AdminConfig): + default_site = 'config.admin.DriveBCAdminSite' diff --git a/src/backend/config/settings/django.py b/src/backend/config/settings/django.py index a65c10a17..12292e603 100644 --- a/src/backend/config/settings/django.py +++ b/src/backend/config/settings/django.py @@ -62,6 +62,7 @@ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", + "django.template.context_processors.csrf", ], }, }, @@ -80,7 +81,6 @@ ] AUTHENTICATION_BACKENDS = [ - # "oauth2_provider.backends.OAuth2Backend", "django.contrib.auth.backends.ModelBackend", # `allauth` specific authentication methods, such as login by email @@ -88,6 +88,7 @@ ] LOGIN_REDIRECT_URL = FRONTEND_BASE_URL +LOGIN_URL = FRONTEND_BASE_URL + 'accounts/oidc/idir/login/?process=login&next=%2Fdrivebc-admin%2F&auth_params=kc_idp_hint=azureidir' # Language USE_I18N = False @@ -98,7 +99,6 @@ # Apps DJANGO_APPS = [ - "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.gis", @@ -131,8 +131,6 @@ "wagtail.documents", "wagtail.images", "wagtail.search", - "wagtail.admin", - "wagtail_modeladmin", "wagtail", "modelcluster", "taggit", @@ -150,7 +148,14 @@ "apps.ferry", ] -INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS +# apps with features overridden in local apps (e.g., admin templates) go here +OVERRIDDEN_APPS = [ + "config.admin.DriveBCAdminConfig", + "wagtail.admin", + "wagtail_modeladmin", +] + +INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS + OVERRIDDEN_APPS # Storage DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/src/backend/config/settings/drivebc.py b/src/backend/config/settings/drivebc.py index a6ca5eb08..51148776c 100644 --- a/src/backend/config/settings/drivebc.py +++ b/src/backend/config/settings/drivebc.py @@ -33,3 +33,8 @@ # Rest Stop API Settings DRIVEBC_REST_STOP_API_BASE_URL=env("DRIVEBC_REST_STOP_API_BASE_URL") + +# Email +ACCESS_REQUEST_RECEIVERS = env('DRIVEBC_ACCESS_REQUEST_RECEIVERS', default='').split(';') +FORCE_IDIR_AUTHENTICATION = env('FORCE_IDIR_AUTHENTICATION', default=False) +DRIVEBC_EMAIL_FEEDBACK_DEFAULT = env("DRIVEBC_FEEDBACK_EMAIL_DEFAULT", default='do_not_reply@gov.bc.ca') diff --git a/src/backend/config/settings/third_party.py b/src/backend/config/settings/third_party.py index 36fe50651..fd0a822aa 100644 --- a/src/backend/config/settings/third_party.py +++ b/src/backend/config/settings/third_party.py @@ -50,26 +50,35 @@ GDAL_LIBRARY_PATH = env("GDAL_LIBRARY_PATH") # Allauth +ACCOUNT_ADAPTER = 'apps.authentication.adapters.AccountAdapter' +SOCIALACCOUNT_ADAPTER = 'apps.authentication.adapters.SocialAccountAdapter' # need our own adapter to override various redirect url methods following # login or logout -ACCOUNT_ADAPTER = 'apps.authentication.adapters.AccountAdapter' SOCIALACCOUNT_PROVIDERS = { 'openid_connect': { - 'APP': { - 'provider_id': 'bceid', - 'name': 'BCeID via Keycloak', - 'client_id': env("BCEID_CLIENT_ID"), - 'secret': env("BCEID_SECRET"), - 'settings': { - 'server_url': env("BCEID_URL"), - } - }, - 'AUTH_PARAMS': { - 'kc_idp_hint': 'bceidbasic', - }, - } + 'APPS': [ + { + 'provider_id': 'bceid', + 'name': 'BCeID via Keycloak', + 'client_id': env("BCEID_CLIENT_ID"), + 'secret': env("BCEID_SECRET"), + 'settings': { + 'server_url': env("BCEID_URL"), + }, + }, + { + 'provider_id': 'idir', + 'name': 'Azure IDIR via Keycloak', + 'client_id': env("BCEID_CLIENT_ID"), + 'secret': env("BCEID_SECRET"), + 'settings': { + 'server_url': env("BCEID_URL"), + }, + }, + ], + }, } SOCIALACCOUNT_EMAIL_VERIFICATION = 'none' diff --git a/src/backend/config/urls.py b/src/backend/config/urls.py index f93511b65..4bfefc296 100644 --- a/src/backend/config/urls.py +++ b/src/backend/config/urls.py @@ -1,11 +1,63 @@ -from apps.shared import views as shared_views -from apps.shared.views import static_override +from allauth.account.decorators import secure_admin_login from django.conf import settings +from django.conf.urls import handler403, defaults from django.contrib import admin +from django.shortcuts import redirect from django.urls import include, path, re_path +from apps.authentication import views as auth_views +from apps.shared import views as shared_views +from apps.shared.views import static_override + + +def admin_permission_denied_handler(request, exception): + ''' + If IDIR use is required, authenicated IDIR accounts get the request + access page; other requests get app specific denial page. + ''' + if settings.FORCE_IDIR_AUTHENTICATION and \ + request.user.username.endswith('azureidir'): + return redirect('admin-request-access') + + return defaults.permission_denied(request, exception, + template_name='admin/access_denied.html') + + +def cms_permission_denied_handler(request, exception): + ''' + If IDIR use is required, authenicated IDIR accounts get the request + access page; other requests get app specific denial page. + ''' + if settings.FORCE_IDIR_AUTHENTICATION and \ + request.user.username.endswith('azureidir'): + return redirect('admin-request-access') + + return defaults.permission_denied(request, exception, + template_name='wagtail_admin/access_denied.html') + + +def permission_denied_handler(request, exception): + ''' Special handler to redirect users for admin/cms to a 'request access' page ''' + + if request.path.startswith('/drivebc-admin/'): + return admin_permission_denied_handler(request, exception) + elif request.path.startswith('/drivebc-cms/'): + return cms_permission_denied_handler(request, exception) + + return defaults.permission_denied(request, exception) + + +admin.autodiscover() + +if settings.FORCE_IDIR_AUTHENTICATION: + admin.site.login = secure_admin_login(admin.site.login) + handler403 = permission_denied_handler + + urlpatterns = [ # django + path('drivebc-admin/request-access', auth_views.request_access, name='admin-request-access'), + path('drivebc-admin/access-requested', auth_views.access_requested, name='admin-access-requested'), path('drivebc-admin/', admin.site.urls), # apps diff --git a/src/backend/requirements/base.txt b/src/backend/requirements/base.txt index 840e163d9..f8a136dd1 100644 --- a/src/backend/requirements/base.txt +++ b/src/backend/requirements/base.txt @@ -1,11 +1,10 @@ Django==4.2.16 -django-allauth==0.63 +django-allauth==65.2.0 django-environ==0.10.0 djangorestframework==3.15.2 djangorestframework-gis==1.0 django-cors-headers==3.13.0 django-filter==23.5 -django-oauth-toolkit==2.4 drf-recaptcha==3.0.0 whitenoise==6.5.0 httpx==0.24.1