diff --git a/.gitignore b/.gitignore index db228a2fa..89035b0c2 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,4 @@ app/babel.config.json app/webpack-stats.json app/webpack.prod.config.js app/yarn.lock +app/react/dist/* \ No newline at end of file diff --git a/app/core/templates/react.html b/app/core/templates/react.html index a6c3050b0..8257baf7e 100644 --- a/app/core/templates/react.html +++ b/app/core/templates/react.html @@ -14,10 +14,21 @@ {{ css_render|safe }} {{ partials.google_analytics|default:''|safe }} +
+ {% render_bundle bundle_name %} {% render_bundle 'commons' %} diff --git a/app/core/views/main.py b/app/core/views/main.py index 757318b77..6c8715bae 100644 --- a/app/core/views/main.py +++ b/app/core/views/main.py @@ -1,5 +1,9 @@ from django.http import HttpResponse, HttpResponseNotFound, HttpResponseServerError from django.shortcuts import redirect, render +from django.views.generic import View +from django.contrib.auth import logout, login, authenticate +from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect +from django.utils.decorators import method_decorator import logging @@ -35,7 +39,7 @@ def help(request): return render(request, "react.html", context) -def handler404(request): +def handler404(request, exception): # Log the 404 URL logger = logging.getLogger(__name__) logger.warning("404 URL: %s", request.path) @@ -51,3 +55,68 @@ def handler404(request): def handler500(request): return "ADSFASDF" + +def create_context(title, bundle_name, js_group_name, css_group_name): + # Get the theme override, if any. + theme_overrides = get_theme_overrides() + + # If a theme override exists, use its JS and CSS. + # Otherwise, use the default JS and CSS. + if theme_overrides: + js_group = ["react", theme_overrides[0][1]["js"]] + css_group = theme_overrides[0][1]["css"] + else: + js_group = ["react", js_group_name] + css_group = css_group_name + + context = {"title": title, "bundle_name": bundle_name, "css_group": css_group} + + return context + +def user_get(request): + """ + API Endpoint to get the current user. + """ + if request.user.is_authenticated: + user = request.user + return HttpResponse(user, content_type="application/json") + else: + return HttpResponse('Not logged in') + +class LoginView(View): + """ + Build a non-SAML login page for users to authenticate. + """ + def get(self, *args, **kwargs): # pylint: disable=unused-argument + if self.request.user.is_authenticated: + return redirect("home page") + else: + context = create_context("Login", "login", "login", "login") + return render(self.request, "react.html", context) + + def post(self, *args, **kwargs): + username = self.request.POST.get("username") + password = self.request.POST.get("password") + # Check the authentication backends for the user + user = authenticate(username=username, password=password) + if user is not None: + login(self.request, user) + return redirect("home page") + else: + context = create_context("Login", "login", "login", "login") + # Add an error message to the context + context["ERR_LOGIN"] = "Invalid username or password" + return render(self.request, "react.html", context) + +class LogoutView(View): + """ + Log the user out of the application. + """ + + def get(self, *args, **kwargs): # pylint: disable=unused-argument + logout(self.request) + return redirect("home page") + + def post(self, *args, **kwargs): + logout(self.request) + return redirect("home page") \ No newline at end of file diff --git a/app/materia/.env b/app/materia/.env new file mode 100644 index 000000000..3ebd8769d --- /dev/null +++ b/app/materia/.env @@ -0,0 +1,2 @@ +STATIC_URL=/static/ +SAML_ENABLED=True \ No newline at end of file diff --git a/app/materia/settings.py b/app/materia/settings.py index f3677f1c7..7212d2a26 100644 --- a/app/materia/settings.py +++ b/app/materia/settings.py @@ -16,7 +16,6 @@ # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent - # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ @@ -41,6 +40,7 @@ # apps "core", "webpack_loader", + "materia_ucfauth.apps.UCFAuthConfig", ] MIDDLEWARE = [ @@ -55,6 +55,11 @@ "whitenoise.middleware.WhiteNoiseMiddleware", ] +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', + 'materia_ucfauth.authentication.saml_backends.SAMLServiceProviderBackend' +] + ROOT_URLCONF = "materia.urls" TEMPLATES = [ @@ -109,6 +114,20 @@ }, ] +# SECURITY +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_NAME = "materia_sessionid" +CSRF_COOKIE_NAME = "materia_csrftoken" +CSRF_COOKIE_HTTPONLY = False +SECURE_BROWSER_XSS_FILTER = True +X_FRAME_OPTIONS = "DENY" + +# SAML CONFIG +LOGIN_URL = "/saml2/login/" +LOGIN_REDIRECT_URL = "/" +LOGOUT_URL = "/saml2/logout/" +SAML_ENABLED = os.environ.get("SAML_ENABLED", "False") == "True" +SAML_FOLDER = os.path.join(BASE_DIR, "materia_ucfauth", "authentication", "saml") # Internationalization # https://docs.djangoproject.com/en/5.0/topics/i18n/ @@ -125,17 +144,19 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.0/howto/static-files/ -STATIC_URL = "/" +STATIC_URL = "/static/" STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") STATICFILES_DIRS = [ os.path.join(BASE_DIR, "public"), ] +MEDIA_URL = "/media/" WEBPACK_LOADER = { "DEFAULT": { "CACHE": False, # Allow for cache in prod since files won't be changing. "BUNDLE_DIR_NAME": "static/", "STATS_FILE": os.path.join(BASE_DIR, "webpack-stats.json"), + 'POLL_INTERVAL': 0.1, } } @@ -165,4 +186,4 @@ "level": "ERROR", # change to DEBUG to see all queries }, }, -} +} \ No newline at end of file diff --git a/app/materia/urls.py b/app/materia/urls.py index 07553aced..538228fe1 100644 --- a/app/materia/urls.py +++ b/app/materia/urls.py @@ -18,11 +18,28 @@ from django.urls import include, path from core.views import main as core_views +from django.conf import settings +from materia_ucfauth.authentication.saml_views import SamlLoginView, SamlLogoutView urlpatterns = [ path("", core_views.index, name="home page"), path("help/", core_views.help, name="help"), + path("api/json/user_get", core_views.user_get, name="user_get"), ] +# Register SAML login routes +if getattr(settings, "SAML_ENABLED", False) == True: + urlpatterns += [ + path("login/", SamlLoginView.as_view(), name="saml_login"), + path("logout/", SamlLogoutView.as_view(), name="saml_logout"), + path(r'saml2/', include('materia_ucfauth.authentication.saml_urls')), + ] +else: + # Register default login routes + urlpatterns += [ + path("login/", core_views.LoginView.as_view(), name="login"), + path("logout/", core_views.LogoutView.as_view(), name="logout"), + ] + handler404 = "core.views.main.handler404" handler500 = "core.views.main.handler500" diff --git a/app/requirements.txt b/app/requirements.txt index c46017cff..2e7e3f9bb 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -3,8 +3,13 @@ Django==5.0.1 mysqlclient==2.2.4 setuptools==69.0.3 sqlparse==0.4.4 +python3-saml==1.16.0 # https://github.com/onelogin/python3-saml uWSGI==2.0.23 wheel==0.42.0 pytz==2024.1 django-webpack-loader==0.7.0 whitenoise +# Pinning version of lxml and xmlsec due to segfault issue with python3-saml +# https://github.com/SAML-Toolkits/python3-saml/issues/389 +lxml == 4.9.3 # https://github.com/lxml/lxml +xmlsec==1.3.13 diff --git a/materia-app.node-python.Dockerfile b/materia-app.node-python.Dockerfile index 0c1e3d7a1..4fa674c26 100644 --- a/materia-app.node-python.Dockerfile +++ b/materia-app.node-python.Dockerfile @@ -32,7 +32,6 @@ COPY ./public /var/www/html/public COPY ./babel.config.json /var/www/html/babel.config.json COPY ./webpack.prod.config.js /var/www/html/webpack.prod.config.js - WORKDIR /var/www/html RUN yarn install @@ -42,6 +41,9 @@ RUN apt-get update && apt-get install -y \ python3-dev \ default-libmysqlclient-dev +# Install MateriaUCFAuth +RUN pip install --user /var/www/html/packages/materia-ucfauth-0.1.tar.gz + RUN pip install -r /var/www/html/requirements.txt USER www-data diff --git a/materia-app.python.Dockerfile b/materia-app.python.Dockerfile index 5605698be..062a3c798 100644 --- a/materia-app.python.Dockerfile +++ b/materia-app.python.Dockerfile @@ -33,7 +33,13 @@ RUN apt-get update RUN pip install --upgrade pip # Install some dependencies necessary for supporting the SAML library -RUN apt-get install -y --no-install-recommends libxmlsec1-dev pkg-config +# 1. libffi-dev is required for ?? +# 2. libxml2-dev and libxmlsec1-dev are required for xmlsec, which is required for python3-saml +# 3. libxml2-dev is required for lxml +RUN apt-get install -y --no-install-recommends \ + pkg-config \ + libffi-dev \ + libxml2-dev=2.9.4+dfsg1-6.1ubuntu1.9 libxmlsec1-dev=1.2.25-1build1 # Install uwsgi now because it takes a little while RUN pip install uwsgi diff --git a/package.json b/package.json index eeb0e9367..80320ee63 100644 --- a/package.json +++ b/package.json @@ -126,5 +126,6 @@ "lines": 43 } } - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/components/login-page.jsx b/src/components/login-page.jsx index 4f9ebf07b..f34ad58b4 100644 --- a/src/components/login-page.jsx +++ b/src/components/login-page.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react' import Header from './header' import Summary from './widget-summary' import './login-page.scss' +import {csrfToken} from '../util/csrf-token' const LoginPage = () => { @@ -29,7 +30,7 @@ const LoginPage = () => { // If there is no redirect query in the url but there is a hash, it will redirect to my-widgets#hash // Otherwise, it adds it onto the end of the redirect query - actionRedirect += (window.location.hash ? window.location.hash : '') + actionRedirect += (window.location.hash ? window.location.hash : '') setState({ loginUser: window.LOGIN_USER, @@ -43,7 +44,7 @@ const LoginPage = () => { widgetName: window.WIDGET_NAME != undefined ? window.WIDGET_NAME : null, isPreview: window.IS_PREVIEW != undefined ? window.IS_PREVIEW : null, loginLinks: links, - errContent: window.ERR_LOGIN != undefined ?

{`${window.ERR_LOGIN}`}

: '', + errContent: window.ERR_LOGIN != undefined && window.ERR_LOGIN != '' ?

{`${window.ERR_LOGIN}`}

: '', noticeContent: window.NOTICE_LOGIN != undefined ?

{`${window.NOTICE_LOGIN}`}

: '' }) }) @@ -63,13 +64,13 @@ const LoginPage = () => { let detailContent = <> if (!state.context || state.context == 'login') { - detailContent = + detailContent =

Log In to Your Account

{`Using your ${state.loginUser} and ${state.loginPw} to access your Widgets.`}
} else if (state.context && state.context == 'widget') { - detailContent = + detailContent =

Log in to play this widget

{`Using your ${state.loginUser} and ${state.loginPw} to access your Widgets.`} @@ -88,6 +89,7 @@ const LoginPage = () => { { state.errContent } { state.noticeContent }
+ - { state.bypass ? + { state.bypass ?