diff --git a/conf/authen_saml2.conf.dist b/conf/authen_saml2.conf.dist
new file mode 100644
index 0000000000..197198a946
--- /dev/null
+++ b/conf/authen_saml2.conf.dist
@@ -0,0 +1,127 @@
+#!perl
+################################################################################
+# Configuration for using Saml2 authentication.
+# To enable this Saml2 authentication, copy this file to conf/authen_saml2.conf
+# and uncomment the appropriate lines in localOverrides.conf. The Saml2
+# authentication module uses the Net::SAML2 library. The library claims to be
+# compatible with a wide range of SAML2 implementations, including Shibboleth.
+################################################################################
+
+# Set Saml2 as the authentication module to use.
+# Comment out 'WeBWorK::Authen::Basic_TheLastOption' if bypassing Saml2
+# authentication is not allowed (see $saml2{bypass_query} below).
+$authen{user_module} = [
+ 'WeBWorK::Authen::Saml2',
+ 'WeBWorK::Authen::Basic_TheLastOption'
+];
+
+# List of authentication modules that may be used to enter the admin course.
+# This is used instead of $authen{user_module} when logging into the admin
+# course. Since the admin course provides overall power to add/delete courses,
+# access to this course should be protected by the best possible authentication
+# you have available to you.
+$authen{admin_module} = [
+ 'WeBWorK::Authen::Saml2'
+];
+
+# Note that Saml2 authentication can be used in conjunction with webwork's two
+# factor authentication. If the identity provider does not provide two factor
+# authentication, then it is recommended that you DO use webwork's two factor
+# authentication. If the identity provider does provide two factor
+# authentication, then you would not want your users need to perform two factor
+# authentication twice, so you should disable webwork's two factor
+# authentication. The two factor authentication settings are set in
+# localOverrides.conf.
+
+# This URL query parameter can be added to the end of a course url to skip the
+# saml2 authentication module and go to the next one, for example,
+# http://your.school.edu/webwork2/courseID?bypassSaml2=1. Comment out the next
+# line to disable this feature.
+$saml2{bypass_query} = 'bypassSaml2';
+
+# If $external_auth is 1, and the authentication sequence reaches
+# Basic_TheLastOption, then the webwork login screen will show a message
+# directing the user to use the external authentication system to login. This
+# prevents users from attempting to login in to WeBWorK directly.
+$external_auth = 0;
+
+# The $saml2{idps} hash contains names of identity proviers and their SAML2
+# metadata URLs that are used by this server. Webwork will request the identity
+# provider's metadata from the URL of the $saml2{active_idp} during the
+# authentication process. Additional identity providers can also be added for a
+# particular course by adding, for example, $saml2{idps}{other_idp} = '...' to
+# the course.conf file of the course. Note that the names of the identity
+# providers in this hash are used for a directory name in which the metadata and
+# certificate for the identity provider are saved. So the names should only
+# contain alpha numeric characters and underscores.
+$saml2{idps} = {
+ default => 'http://idp/simplesaml/module.php/saml/idp/metadata',
+ # Add additional identity providers used by this server below.
+ #other_idp => 'http://other.idp.server/metadata',
+};
+
+# The $saml2{active_idp} is the identity provider in the $saml2{idps} hash that
+# will be used. If different identity providers are used for different courses,
+# then set $saml2{active_idp} = 'other_idp' in the course.conf file of each
+# course.
+$saml2{active_idp} = 'default';
+
+# This the id for the webwork2 service provider. This is usually the application
+# root URL plus the base path to the service provider.
+$saml2{sp}{entity_id} = 'http://localhost:8080/webwork2/saml2';
+
+# This is the organization metadata information for the webwork2 service
+# provider. The Saml2 authentication module will generate xml metadata that can
+# be obtained by the identity provider for configuration from the URL
+# https://webwork.yourschool.edu/webwork2/saml2/metadata if Saml2 authentication
+# is enabled site wide. The URL needs to have the courseID URL parameter added
+# if Saml2 authentication is not enabled site wide, but is enabled for some
+# courses in those course's course.conf files. So for example if one course is
+# myTestCourse, then the metadata URL would be
+# https://webwork.yourschool.edu/webwork2/saml2/metadata?courseID=myTestCourse
+# Further note that if multiple courses use that same identity provider then
+# just pick any one of the courses to use in the metadata URL. All of the other
+# courses share the same metedata.
+$saml2{sp}{org} = {
+ contact => 'webwork@example.edu',
+ name => 'webwork',
+ url => 'https://localhost:8080/',
+ display_name => 'WeBWorK'
+};
+
+# The following list of attributes will be checked in the given order for a
+# matching user in the webwork2 course. If no attributes are given, then
+# webwork2 will default to the NameID. It is recommended that you use the
+# attribute's OID.
+$saml2{sp}{attributes} = [
+ 'urn:oid:0.9.2342.19200300.100.1.1'
+];
+
+# The following settings are the locations of the files that contain the
+# certificate and private key for the webwork2 service provider. A certificate
+# and private key can be generated using openssl. For example,
+# openssl req -newkey rsa:3072 -new -x509 -days 3652 -nodes -out saml.crt -keyout saml.pem
+# The files saml.crt and saml.pem that are generated contain the public
+# "certificate" and the "private_key", respectively.
+# Note that if the files are placed within the root webwork2 app directory, then
+# the paths may be given relative to the the root webwork2 app directory.
+# Otherwise the absolute path must be given. Make sure that the webwork2 app has
+# read permissions for those files.
+$saml2{sp}{certificate_file} = 'docker-config/idp/certs/saml.crt';
+$saml2{sp}{private_key_file} = 'docker-config/idp/certs/saml.pem';
+
+##############################################################################
+# SECURITY WARNING
+# For production, you MUST provide your own unique 'certificate' and
+# 'private_key' files. The files referred to in the default settings above are
+# only intended to be used in development, and are publicly exposed. Hence, they
+# provide NO SECURITY.
+##############################################################################
+
+# If this is set to 1, then service provider initiated logout from the identity
+# provider is enabled. This means that when the user clicks the webwork2 "Log
+# Out" button, a request is sent to the identity provider that also ends the
+# session for the user with the identity provider.
+$saml2{sp}{enable_sp_initiated_logout} = 0;
+
+1;
diff --git a/conf/authen_saml2.dist.yml b/conf/authen_saml2.dist.yml
deleted file mode 100644
index 6200516132..0000000000
--- a/conf/authen_saml2.dist.yml
+++ /dev/null
@@ -1,120 +0,0 @@
----
-################################################################################
-# Configuration for the Saml2 plugin
-#
-# To enable the Saml2 plugin, copy authen_saml2.dist.yml to authen_saml2.yml
-#
-# The Saml2 plugin uses the Net::SAML2 library, the library claims to be
-# compatible with a wide range of SAML2 implementations, including Shibboleth.
-################################################################################
-
-# add this query to the end of a course url to skip the saml2 authen module
-# and go to the next one, comment out to disable this feature
-bypass_query: bypassSaml2
-idp: # this is the central SAML2 server
- # url where we can get the SAML2 metadata xml for the IdP
- metadata_url: http://idp.docker:8180/simplesaml/module.php/saml/idp/metadata
-sp: # this is the Webwork side
- # also known as iss (issuer)
- entity_id: http://localhost:8080/saml2
- # endoints created by the plugin, relative to the webwork root url
- route:
- base: '/saml2' # prefix path for all URLs handled by the plugin
- metadata: '/metadata' # actual path would be /saml2/metadata
- # 'Assertion Consumer Service', basically handles the SAML response, plugin
- # only supports a POST response (HTTP POST Binding)
- acs:
- post: '/acs/post'
- # Ideally, there would be a way to have separate app info and org info but
- # Net::SAML2's metadata generation doesn't seem to have that separation. So
- # I've filled out the org info with app info instead.
- org:
- contact: 'webwork@example.edu'
- name: 'webwork'
- url: 'https://localhost:8080/'
- display_name: 'WeBWorK'
- # list of attributes that can be used as the username, each of them will be
- # tried in turn to see if there's a matching user in the classlist. If no
- # attributes are given, then we'll default to the NameID. Please use the
- # attribute's OID
- attributes:
- - 'urn:oid:0.9.2342.19200300.100.1.1'
- ##############################################################################
- # SECURITY WARNING
- # For production, you MUST generate your own unique 'cert' and 'signing_key'.
- # The examples below are publicly exposed and thus provides NO SECURITY.
- ##############################################################################
- # Cert and key pairs can be generated using an openssl command such as:
- # openssl req -newkey rsa:3072 -new -x509 -days 3652 -nodes -out webwork.crt -keyout webwork.pem
- # Where webwork.crt contains the cert and webwork.pem contains the signing_key
- cert: |
- -----BEGIN CERTIFICATE-----
- MIIE7zCCA1egAwIBAgIUIteyNYLSAiB0FcNl0GLJNYRppk8wDQYJKoZIhvcNAQEL
- BQAwgYYxCzAJBgNVBAYTAkFBMQswCQYDVQQIDAJBQTEQMA4GA1UEBwwHRXhhbXBs
- ZTEQMA4GA1UECgwHRXhhbXBsZTEQMA4GA1UECwwHRXhhbXBsZTEQMA4GA1UEAwwH
- RXhhbXBsZTEiMCAGCSqGSIb3DQEJARYTZXhhbXBsZUBleGFtcGxlLmVkdTAeFw0y
- NDA1MDMwMTA2MzNaFw0zNDA1MDMwMTA2MzNaMIGGMQswCQYDVQQGEwJBQTELMAkG
- A1UECAwCQUExEDAOBgNVBAcMB0V4YW1wbGUxEDAOBgNVBAoMB0V4YW1wbGUxEDAO
- BgNVBAsMB0V4YW1wbGUxEDAOBgNVBAMMB0V4YW1wbGUxIjAgBgkqhkiG9w0BCQEW
- E2V4YW1wbGVAZXhhbXBsZS5lZHUwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGK
- AoIBgQC45DCHUejzAeq+eVwEX5zSQWC+kqydEmoxpydT4YiSXnNeoNAkilKfHGOY
- Uc4djwx148N14A+S0GCys2j3Ey2wuL7DSep5y1Z9Uxj6Ayg23XGIFFFJJLMy1Qfe
- pjCcr1djPH9PpwglG1nTsiWqvHGGc3WWn1u6RfyrCf+jxbhygNRTA+LVPpqNvko6
- MWKsbVLKrYMV2kPcQ0PQByNHJnjBy3KH2k99lS20h32sgHbgbVpJWdAWjeyJrOh9
- aDt4/AfK90BvhkjF4BuQ+Jw5oIwMhbx7YmzIfiJmBLLaGjVRppuoAQtLX9uLst9l
- aLZzaeutg+G3RUYcvDMlnP7cU8Sq4BD7uK0ChKxxCMFcihAhQ8wqCKncaaE9WqPs
- CM16SB/6xptOxoLcg/5q3PJyUi2g4VDKXuQc6AURKIJxSM9nlrcv/R7fCFgk3Nj/
- piWykDk6/BDWFpEHaj+NnFE9ZIxKr9CjTxdmqiDTyqSv50rNCjleyL/iASBTSCCF
- OPVOYQECAwEAAaNTMFEwHQYDVR0OBBYEFGs8F3VIGSEk+DE2MBqqNKX6UuZTMB8G
- A1UdIwQYMBaAFGs8F3VIGSEk+DE2MBqqNKX6UuZTMA8GA1UdEwEB/wQFMAMBAf8w
- DQYJKoZIhvcNAQELBQADggGBAIpDktpfGH7ZqgdWvxbJrjekb1IyCGrWsHOYSjwM
- +MxnhAA6oY63wC04a2i31zIMNOkY9F0tAdd4uDchxA9IWHqpb7t7zBlZdDabPPC3
- WoDYnKhtZBULVVo7AvWO0UJGfZNJE393aKer3ePvfoG0OpCyrw4eFI/GCd4UjJBF
- DnD7hvUxE7RRwOhbuYrtDRuB3Z7CeeP8o81eDVexyuBpM/9UQjYPqBBAfoeYKQzu
- ZIhpGRWXw0ntH+EEOWagRXA5pRru61hteParZe4LBjPqisqN4Ek6ZR7MD9gB5xnt
- Pn1BKRY08quFOZyaogzwfkYk5SCF8F8jBA8ZNAYwJWe1gtO3iw5vpUaQc2iCabvI
- Y+Pc6qsSNwbkl7+sFrVHzI9QZVyz1cARUXxvrgGNLBkYtprkG91k6mCjX90cQspb
- ZwHixcQyCNv+4H738e99h/Wf0YzjxFjDKrbGoosYBzWAsYYtzrtsBvw3SJMTXIh7
- OvFMA+rbIL8XWs8oNmZDDh8g0A==
- -----END CERTIFICATE-----
- signing_key: |
- -----BEGIN PRIVATE KEY-----
- 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
- dzDq+xUD8nHpKM33A2EaUFY=
- -----END PRIVATE KEY-----
diff --git a/conf/localOverrides.conf.dist b/conf/localOverrides.conf.dist
index 39b4a02afe..a660b50bcf 100644
--- a/conf/localOverrides.conf.dist
+++ b/conf/localOverrides.conf.dist
@@ -527,6 +527,16 @@ $mail{feedbackRecipients} = [
#include("conf/authen_ldap.conf");
+################################################################################
+# Saml2 Authentication
+################################################################################
+# Uncomment the following line to enable authentication via a Saml2 identity
+# provider. You will also need to copy the file authen_saml2.conf.dist to
+# authen_saml2.conf, and then edit that file to fill in the settings for your
+# installation.
+
+#include("conf/authen_saml2.conf");
+
################################################################################
# Session Management
################################################################################
diff --git a/docker-config/docker-compose.dist.yml b/docker-config/docker-compose.dist.yml
index a168a9f96e..6a8369a721 100644
--- a/docker-config/docker-compose.dist.yml
+++ b/docker-config/docker-compose.dist.yml
@@ -251,47 +251,27 @@ services:
#ports:
# - "6311:6311"
- # saml2 dev use only, separate profile from the other services so it doesn't
- # start in normal usage. Use "docker compose --profile saml2dev up" to start.
+ # SimpleSAMLphp Saml2 identity provider for development use only. This is a separate profile from the other services
+ # so it doesn't start in normal usage. Use "docker compose --profile saml2dev up" to start, and "docker compose
+ # --profile saml2dev down" to stop.
idp:
build:
- context: ./docker-config/idp/ # SimpleSAMLphp based IDP
+ context: ./docker-config/idp/
profiles:
- saml2dev
ports:
- '8180:80'
environment:
- SP_METADATA_URL: 'http://app.docker:8080/saml2/metadata'
- # the healthcheck url is simplesamlphp's url for triggering cron jobs, the
- # cron job it'll trigger is to automatically grab webwork sp's metadata
+ SP_METADATA_URL: 'http://app:8080/webwork2/saml2/metadata'
+ # The healthcheck url is SimpleSAMLphp's url for triggering cron jobs. The cron job it triggers will
+ # automatically fetch the webwork2 service provider's metadata.
healthcheck:
- test: ['CMD', 'curl', '-f', 'http://localhost/simplesaml/module.php/cron/run/docker/healthcheck']
+ test: ['CMD', 'curl', '-f', 'http://localhost/simplesaml/module.php/cron/run/metarefresh/webwork2']
start_period: 1m
start_interval: 15s
interval: 1h
retries: 1
timeout: 10s
- # Send internal docker traffic for the idp external port to the idp internal
- # port. Needed so the webwork saml2 plugin can request the idp metadata.
- socat-idp:
- image: alpine/socat:1.8.0.0
- profiles:
- - saml2dev
- command: 'TCP-LISTEN:8180,fork,reuseaddr TCP:idp:80'
- networks:
- default:
- aliases:
- - idp.docker
- # same port redirect so the idp can get the webwork saml2 plugin metadata
- socat-app:
- image: alpine/socat:1.8.0.0
- profiles:
- - saml2dev
- command: 'TCP-LISTEN:8080,fork,reuseaddr TCP:app:8080'
- networks:
- default:
- aliases:
- - app.docker
volumes:
oplVolume:
diff --git a/docker-config/idp/Dockerfile b/docker-config/idp/Dockerfile
index a9dac13770..876ba9f7d0 100644
--- a/docker-config/idp/Dockerfile
+++ b/docker-config/idp/Dockerfile
@@ -1,8 +1,7 @@
-# actual image we'll run in the end
FROM php:8.3-apache
WORKDIR /var/www
-# Install composer & php extension installer
+# Install composer and the php extension installer.
COPY --from=composer/composer:2-bin /composer /usr/bin/composer
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
@@ -10,27 +9,30 @@ RUN apt-get update && \
apt-get -y install git curl vim && \
install-php-extensions ldap zip
-# dirs used by simplesamlphp needs to be accessible by apache user
+# Directories used by simplesamlphp. These need to be accessible by the apache2 user.
RUN mkdir simplesamlphp/ /var/cache/simplesamlphp
-RUN chown www-data. simplesamlphp/ /var/cache/simplesamlphp
-# Composer doesn't like to be root, so we'll run the rest as the apache user
+RUN chown www-data simplesamlphp/ /var/cache/simplesamlphp
+
+COPY ./idp.apache2.conf /etc/apache2/conf-available
+RUN a2enconf idp.apache2
+
+# Composer doesn't like to be root, so run the rest as the apache user.
USER www-data
# Install simplesamlphp
-ARG SIMPLESAMLPHP_TAG=v2.2.1
-RUN git clone --branch $SIMPLESAMLPHP_TAG https://github.com/simplesamlphp/simplesamlphp.git
+RUN git clone --branch v2.2.1 https://github.com/simplesamlphp/simplesamlphp.git
WORKDIR /var/www/simplesamlphp
-# Generate certs
+# Generate the server certificates.
RUN cd cert/ && \
- openssl req -newkey rsa:3072 -new -x509 -days 3652 -nodes -out server.crt -keyout server.pem -subj "/C=CA/SP=BC/L=Vancouver/O=UBC/CN=idp.docker"
+ openssl req -newkey rsa:3072 -new -x509 -days 3652 -nodes -out server.crt -keyout server.pem \
+ -subj "/C=US/S=New York/L=Rochester/O=WeBWorK/CN=idp.webwork2"
-# Use composer to install dependencies
+# Use composer to install dependencies.
RUN composer install && \
composer require simplesamlphp/simplesamlphp-module-metarefresh
-# Copy config files
+# Copy configuration files.
COPY ./config/ config/
COPY ./metadata/ metadata/
-COPY ./apache.conf /etc/apache2/sites-available/000-default.conf
diff --git a/docker-config/idp/README.md b/docker-config/idp/README.md
new file mode 100644
index 0000000000..586b9d9953
--- /dev/null
+++ b/docker-config/idp/README.md
@@ -0,0 +1,174 @@
+# Development identity provider test instance for SAML2 authentication
+
+A development SAML2 identity provider is provided that uses SimpleSAMLphp.
+Instructions for utilizing this instance follow.
+
+## Webwork2 Configuration
+
+Copy `/opt/webwork/webwork2/conf/authen_saml2.conf.dist` to
+`/opt/webwork/webwork2/conf/authen_saml2.conf`.
+
+The default `conf/authen_saml2.conf.dist` is configured to use the docker
+identity provider. So for the docker build, it should work as is.
+
+Without the docker build a few changes are needed.
+
+- Find the `$saml2{idps}{default}` setting and change its value to
+ `'http://localhost/simplesaml/module.php/saml/idp/metadata'`.
+- Find the `$saml2{sp}{entity_id}` setting and change its value to
+ `'http://localhost:3000/webwork2/saml2'`.
+- In the `$saml2{sp}{org}` hash change the `url` to `'https://localhost:3000/'`.
+
+The above settings assume you will use `morbo` with the default port. Change
+the port as needed.
+
+## Development IdP test instance with docker
+
+A docker service that implements a SAML2 identity provider is provided in the
+`docker-compose.yml.dist` file. To start this identity provider along with the
+rest of webwork2, add the `--profile saml2dev` argument to docker compose as in
+the following exmaple.
+
+```bash
+docker compose --profile saml2dev up
+```
+
+Without the profile argument, the identity provider services do not start.
+
+Stop all docker services with
+
+```bash
+docker compose --profile saml2dev down
+```
+
+## Development IdP test instance without docker
+
+Effective development is not done with docker. So it is usually more useful to
+set up an identity provider without docker. The following instructions are for
+Ubuntu 24.04, but could be adapted for other operating systems.
+
+A web server and php are needed to serve the SimpleSAMLphp files. Install these
+and other dependencies with:
+
+```bash
+sudo apt install \
+ apache2 php php-ldap php-zip php-xml php-curl php-sqlite3 php-fpm \
+ composer
+```
+
+Now download the SimpleSAMLphp source, install php dependencies, install the
+SimpleSAMLphp metarefresh module, and set file permissions with
+
+```bash
+cd /var/www
+sudo mkdir simplesamlphp /var/cache/simplesamlphp
+sudo chown $USER:www-data simplesamlphp
+sudo chown www-data /var/cache/simplesamlphp
+git clone --branch v2.2.1 https://github.com/simplesamlphp/simplesamlphp.git
+sudo chown -R $USER:www-data simplesamlphp
+sudo chmod -R g+w simplesamlphp
+cd simplesamlphp
+composer install
+composer require simplesamlphp/simplesamlphp-module-metarefresh
+```
+
+Next, generate certificates for the SimpleSAMLphp identity provider and make
+them owned by the `www-data` user with
+
+```bash
+cd /var/www/simplesamlphp/cert
+openssl req -newkey rsa:3072 -new -x509 -days 3652 -nodes \
+ -out server.crt -keyout server.pem \
+ -subj "/C=US/ST=New York/L=Rochester/O=WeBWorK/CN=idp.webwork2"
+sudo chown www-data:www-data server.crt server.pem
+```
+
+Next, copy the `idp` configuration files from `docker-config`.
+
+```bash
+cp /opt/webwork/webwork2/docker-config/idp/config/* /var/www/simplesamlphp/config/
+cp /opt/webwork/webwork2/docker-config/idp/metadata/* /var/www/simplesamlphp/metadata/
+```
+
+The configuration files are setup to work with the docker build. So there are
+some changes that are needed.
+
+Edit the file `/var/www/simplesamlphp/config/config.php` and change
+`baseurlpath` to `simplesaml/`.
+
+Edit the file `/var/www/simplesamlphp/metadata/saml20-idp-hosted.php` and change
+the line that reads
+`$metadata['http://localhost:8180/simplesaml'] = [`
+to
+`$metadata['http://localhost/simplesaml'] = [`.
+
+Enable the apache2 idp configuration with
+
+```bash
+sudo cp /opt/webwork/webwork2/docker-config/idp/idp.apache2.conf /etc/apache2/conf-available
+sudo a2enconf idp.apache2 php8.3-fpm
+```
+
+Edit the file `/etc/apache2/conf-available/idp.apache2.conf` and add the line
+`SetEnv SP_METADATA_URL http://localhost:3000/webwork2/saml2/metadata` to the
+beginning of the file. This again assumes you will use `morbo` with the default
+port, so change the port if necessary.
+
+Restart (or start) apache2 with `sudo systemctl restart apache2`.
+
+The SimpleSAMLphp identity provider needs to fetch webwork2's service provider
+metadata. For this execute
+
+```bash
+curl -f http://localhost/simplesaml/module.php/cron/run/metarefresh/webwork2
+```
+
+That is done automatically with the docker build. The command usually only
+needs to be done once, but may need to be run again if settings are changed.
+
+## Identity provider administration
+
+The identity provider has an admin interface. You can login to the docker
+instance with the password 'admin' at
+`http://localhost:8180/simplesaml/module.php/admin/federation`
+or without docker at
+`http://localhost/simplesaml/module.php/admin/federation`.
+
+The admin interface lets you check if the identity provider has properly
+registered the webwork2 service provider under the 'Federation' tab, it should
+be listed under the "Trusted entities" section.
+
+You can also test login with the user accounts listed below in the "Test" tab
+under the "example-userpass" authentication source.
+
+## Single sign-on users
+
+The following single sign-on accounts are preconfigured:
+
+- Username: student01, Password: student01
+- Username: instructor01, Password: instructor01
+- Username: staff01, Password: staff01
+
+You can add more accounts to the `docker-config/idp/config/authsources.php` file
+in the `example-userpass` section. If using docker the identity provider, the
+image will need to be rebuilt for the changes to take effect.
+
+## Troubleshooting
+
+### "Error retrieving metadata"
+
+This error message indicates that the Saml2 authentication module wasn't able to
+fetch the metadata from the identity provider metadata URL. Make sure the
+identity provider is accessible to webwork2.
+
+### User not found in course
+
+The user was verified by the identity provider but did not have a corresponding
+user account in the Webwork course. The Webwork user account needs to be created
+separately as the Saml2 autentication module does not do user provisioning.
+
+### The WeBWorK service provider does not appear in the service provider Federation tab
+
+This can occur when using the docker identity provider service because Webwork's
+first startup can be slow enough that the IdP wasn't able to successfully fetch
+metadata from the webwork2 metadata URL. Restarting everything should fix this.
diff --git a/docker-config/idp/apache.conf b/docker-config/idp/apache.conf
deleted file mode 100644
index bbe3dc3eed..0000000000
--- a/docker-config/idp/apache.conf
+++ /dev/null
@@ -1,38 +0,0 @@
-
- # The ServerName directive sets the request scheme, hostname and port that
- # the server uses to identify itself. This is used when creating
- # redirection URLs. In the context of virtual hosts, the ServerName
- # specifies what hostname must appear in the request's Host: header to
- # match this virtual host. For the default virtual host (this file) this
- # value is not decisive as it is used as a last resort host regardless.
- # However, you must set it for any further virtual host explicitly.
- #ServerName www.example.com
-
- ServerAdmin webmaster@localhost
- DocumentRoot /var/www/simplesamlphp
-
- # Available loglevels: trace8, ..., trace1, debug, info, notice, warn,
- # error, crit, alert, emerg.
- # It is also possible to configure the loglevel for particular
- # modules, e.g.
- #LogLevel info ssl:warn
-
- ErrorLog ${APACHE_LOG_DIR}/error.log
- CustomLog ${APACHE_LOG_DIR}/access.log combined
-
- # For most configuration files from conf-available/, which are
- # enabled or disabled at a global level, it is possible to
- # include a line for only one particular virtual host. For example the
- # following line enables the CGI configuration for this host only
- # after it has been globally disabled with "a2disconf".
- #Include conf-available/serve-cgi-bin.conf
-
- SetEnv SIMPLESAMLPHP_CONFIG_DIR /var/www/simplesamlphp/config
-
- Alias /simplesaml /var/www/simplesamlphp/public
-
-
- Require all granted
-
-
-
diff --git a/docker-config/idp/certs/saml.crt b/docker-config/idp/certs/saml.crt
new file mode 100644
index 0000000000..ca2f952c0f
--- /dev/null
+++ b/docker-config/idp/certs/saml.crt
@@ -0,0 +1,29 @@
+-----BEGIN CERTIFICATE-----
+MIIE7zCCA1egAwIBAgIUIteyNYLSAiB0FcNl0GLJNYRppk8wDQYJKoZIhvcNAQEL
+BQAwgYYxCzAJBgNVBAYTAkFBMQswCQYDVQQIDAJBQTEQMA4GA1UEBwwHRXhhbXBs
+ZTEQMA4GA1UECgwHRXhhbXBsZTEQMA4GA1UECwwHRXhhbXBsZTEQMA4GA1UEAwwH
+RXhhbXBsZTEiMCAGCSqGSIb3DQEJARYTZXhhbXBsZUBleGFtcGxlLmVkdTAeFw0y
+NDA1MDMwMTA2MzNaFw0zNDA1MDMwMTA2MzNaMIGGMQswCQYDVQQGEwJBQTELMAkG
+A1UECAwCQUExEDAOBgNVBAcMB0V4YW1wbGUxEDAOBgNVBAoMB0V4YW1wbGUxEDAO
+BgNVBAsMB0V4YW1wbGUxEDAOBgNVBAMMB0V4YW1wbGUxIjAgBgkqhkiG9w0BCQEW
+E2V4YW1wbGVAZXhhbXBsZS5lZHUwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGK
+AoIBgQC45DCHUejzAeq+eVwEX5zSQWC+kqydEmoxpydT4YiSXnNeoNAkilKfHGOY
+Uc4djwx148N14A+S0GCys2j3Ey2wuL7DSep5y1Z9Uxj6Ayg23XGIFFFJJLMy1Qfe
+pjCcr1djPH9PpwglG1nTsiWqvHGGc3WWn1u6RfyrCf+jxbhygNRTA+LVPpqNvko6
+MWKsbVLKrYMV2kPcQ0PQByNHJnjBy3KH2k99lS20h32sgHbgbVpJWdAWjeyJrOh9
+aDt4/AfK90BvhkjF4BuQ+Jw5oIwMhbx7YmzIfiJmBLLaGjVRppuoAQtLX9uLst9l
+aLZzaeutg+G3RUYcvDMlnP7cU8Sq4BD7uK0ChKxxCMFcihAhQ8wqCKncaaE9WqPs
+CM16SB/6xptOxoLcg/5q3PJyUi2g4VDKXuQc6AURKIJxSM9nlrcv/R7fCFgk3Nj/
+piWykDk6/BDWFpEHaj+NnFE9ZIxKr9CjTxdmqiDTyqSv50rNCjleyL/iASBTSCCF
+OPVOYQECAwEAAaNTMFEwHQYDVR0OBBYEFGs8F3VIGSEk+DE2MBqqNKX6UuZTMB8G
+A1UdIwQYMBaAFGs8F3VIGSEk+DE2MBqqNKX6UuZTMA8GA1UdEwEB/wQFMAMBAf8w
+DQYJKoZIhvcNAQELBQADggGBAIpDktpfGH7ZqgdWvxbJrjekb1IyCGrWsHOYSjwM
++MxnhAA6oY63wC04a2i31zIMNOkY9F0tAdd4uDchxA9IWHqpb7t7zBlZdDabPPC3
+WoDYnKhtZBULVVo7AvWO0UJGfZNJE393aKer3ePvfoG0OpCyrw4eFI/GCd4UjJBF
+DnD7hvUxE7RRwOhbuYrtDRuB3Z7CeeP8o81eDVexyuBpM/9UQjYPqBBAfoeYKQzu
+ZIhpGRWXw0ntH+EEOWagRXA5pRru61hteParZe4LBjPqisqN4Ek6ZR7MD9gB5xnt
+Pn1BKRY08quFOZyaogzwfkYk5SCF8F8jBA8ZNAYwJWe1gtO3iw5vpUaQc2iCabvI
+Y+Pc6qsSNwbkl7+sFrVHzI9QZVyz1cARUXxvrgGNLBkYtprkG91k6mCjX90cQspb
+ZwHixcQyCNv+4H738e99h/Wf0YzjxFjDKrbGoosYBzWAsYYtzrtsBvw3SJMTXIh7
+OvFMA+rbIL8XWs8oNmZDDh8g0A==
+-----END CERTIFICATE-----
diff --git a/docker-config/idp/certs/saml.pem b/docker-config/idp/certs/saml.pem
new file mode 100644
index 0000000000..65accf00b2
--- /dev/null
+++ b/docker-config/idp/certs/saml.pem
@@ -0,0 +1,40 @@
+-----BEGIN PRIVATE KEY-----
+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
+dzDq+xUD8nHpKM33A2EaUFY=
+-----END PRIVATE KEY-----
diff --git a/docker-config/idp/config/authsources.php b/docker-config/idp/config/authsources.php
index 608ab59c30..03740f20bf 100644
--- a/docker-config/idp/config/authsources.php
+++ b/docker-config/idp/config/authsources.php
@@ -67,7 +67,6 @@
// */
//],
-
/*
'example-sql' => [
'sqlauth:SQL',
diff --git a/docker-config/idp/config/config.php b/docker-config/idp/config/config.php
index b42a1e77f5..3ecbe1d3ad 100644
--- a/docker-config/idp/config/config.php
+++ b/docker-config/idp/config/config.php
@@ -31,7 +31,7 @@
* external url, no matter where you come from (direct access or via the
* reverse proxy).
*/
- 'baseurlpath' => 'simplesaml/',
+ 'baseurlpath' => 'http://localhost:8180/simplesaml/',
/*
* The 'application' configuration array groups a set configuration options
@@ -173,9 +173,7 @@
*
* See this page for a list of valid timezones: http://php.net/manual/en/timezones.php
*/
- 'timezone' => 'America/Vancouver',
-
-
+ 'timezone' => 'America/New_York',
/**********************************
| SECURITY CONFIGURATION OPTIONS |
@@ -281,7 +279,7 @@
'X-Frame-Options' => 'SAMEORIGIN',
'X-Content-Type-Options' => 'nosniff',
'Referrer-Policy' => 'origin-when-cross-origin',
-],
+ ],
*/
diff --git a/docker-config/idp/config/module_cron.php b/docker-config/idp/config/module_cron.php
index 63a66ba3ed..a05be61da2 100644
--- a/docker-config/idp/config/module_cron.php
+++ b/docker-config/idp/config/module_cron.php
@@ -1,8 +1,8 @@
'healthcheck',
- 'allowed_tags' => ['docker'],
+ 'key' => 'webwork2',
+ 'allowed_tags' => ['metarefresh'],
'debug_message' => true,
'sendemail' => false,
];
diff --git a/docker-config/idp/config/module_metarefresh.php b/docker-config/idp/config/module_metarefresh.php
index 6c8975e524..1b2cf60d61 100644
--- a/docker-config/idp/config/module_metarefresh.php
+++ b/docker-config/idp/config/module_metarefresh.php
@@ -1,17 +1,19 @@
[
- 'webwork' => [
- 'cron' => ['docker'],
+ 'webwork2' => [
+ 'cron' => ['metarefresh'],
'sources' => [
- ['src' => $_ENV['SP_METADATA_URL']]
+ ['src' => $metadataURL]
],
- 'expiresAfter' => 60*60*24*365*10, // 10 years, basically never
+ 'expiresAfter' => 60 * 60 * 24 * 365 * 10, // 10 years, basically never
'outputDir' => 'metadata/metarefresh-webwork/',
'outputFormat' => 'flatfile',
]
diff --git a/docker-config/idp/idp.apache2.conf b/docker-config/idp/idp.apache2.conf
new file mode 100644
index 0000000000..5f2e656ebe
--- /dev/null
+++ b/docker-config/idp/idp.apache2.conf
@@ -0,0 +1,7 @@
+SetEnv SIMPLESAMLPHP_CONFIG_DIR /var/www/simplesamlphp/config
+
+Alias /simplesaml /var/www/simplesamlphp/public
+
+
+ Require all granted
+
diff --git a/lib/Mojolicious/Plugin/Saml2/Controller/AcsPostController.pm b/lib/Mojolicious/Plugin/Saml2/Controller/AcsPostController.pm
deleted file mode 100644
index 1f1553fb04..0000000000
--- a/lib/Mojolicious/Plugin/Saml2/Controller/AcsPostController.pm
+++ /dev/null
@@ -1,101 +0,0 @@
-package Mojolicious::Plugin::Saml2::Controller::AcsPostController;
-
-use Mojo::Base 'WeBWorK::Controller', -signatures, -async_await;
-
-use Mojo::JSON qw(decode_json);
-use Net::SAML2::Binding::POST;
-use Net::SAML2::Protocol::Assertion;
-
-use WeBWorK::Authen::Saml2;
-use WeBWorK::CourseEnvironment;
-use WeBWorK::DB;
-use WeBWorK::Debug qw(debug);
-
-async sub post ($c) {
- debug('SAML2 is on!');
- # check required params
- my $samlResp = $c->param('SAMLResponse');
- if (!$samlResp) {
- return $c->reply->exception('Unauthorized - Missing SAMLResponse')->rendered(401);
- }
- my $relayState = $c->param('RelayState');
- if (!$relayState) {
- return $c->reply->exception('Unauthorized - Missing RelayState')->rendered(401);
- }
- $relayState = decode_json($relayState);
-
- my $idp = $c->saml2->getIdp();
- my $conf = $c->saml2->getConf();
-
- # verify response is signed by the IdP and decode it
- my $postBinding = Net::SAML2::Binding::POST->new(cacert => $c->saml2->getIdpCertFile());
- my $decodedXml = $postBinding->handle_response($samlResp);
- my $assertion = Net::SAML2::Protocol::Assertion->new_from_xml(
- xml => $decodedXml,
- key_file => $c->saml2->getSpSigningKeyFile()
- );
-
- $c->_actAsWebworkController($relayState->{course});
- # get the authReqId we generated when we sent the user to the IdP
- my $authReqId = $c->session->{authReqId};
- delete $c->session->{authReqId}; # delete from session to avoid replay
-
- # verify the response has the same authReqId which means it's responding to
- # the auth request we generated, also checks that timestamps are valid
- my $valid = $assertion->valid($conf->{sp}{entity_id}, $authReqId);
- if (!$valid) {
- return $c->reply->exception('Unauthorized - Bad timestamp or issuer')->rendered(401);
- }
-
- debug('Got valid response and looking for username');
- my $userId = $c->_getUserId($conf->{sp}{attributes}, $assertion, $relayState);
- if ($userId) {
- debug("Got username $userId");
- $c->authen->setSaml2UserId($userId);
- if (!$c->authen->verify()) {
- debug("Saml2 User Verify Failed");
- debug("Rendering WeBWorK::ContentGenerator::Login");
- return await WeBWorK::ContentGenerator::Login->new($c)->go();
- }
- return $c->redirect_to($relayState->{url});
- }
- return $c->reply->exception('Unauthorized - User not found in ' . $relayState->{course})->rendered(401);
-}
-
-sub _actAsWebworkController ($c, $courseName) {
- # we need to call Webwork authen module to create the auth session, so our
- # controller need to have the things that the authen module needs to use
- $c->stash('courseID', $courseName);
- $c->ce(WeBWorK::CourseEnvironment->new({ courseName => $courseName }));
- $c->db(WeBWorK::DB->new($c->ce->{dbLayout}));
- my $authz = WeBWorK::Authz->new($c);
- $c->authz($authz);
- my $authen = WeBWorK::Authen::Saml2->new($c);
- $c->authen($authen);
-}
-
-sub _getUserId ($c, $attributeKeys, $assertion, $relayState) {
- my $ce = $c->{ce};
- my $db = $c->{db};
- my $user;
- if ($attributeKeys) {
- foreach my $key (@$attributeKeys) {
- debug("Trying attribute $key for username");
- my $possibleUserId = $assertion->attributes->{$key}->[0];
- if (!$possibleUserId) { next; }
- if ($db->getUser($possibleUserId)) {
- debug("Using attribute value for username: $possibleUserId");
- return $possibleUserId;
- }
- }
- }
- debug("No username match in attributes, trying NameID fallback");
- if ($db->getUser($assertion->nameid)) {
- debug("Using NameID for username: " . $assertion->nameid);
- return $assertion->nameid;
- }
- debug("NameID fallback failed, no username possible");
- return '';
-}
-
-1;
diff --git a/lib/Mojolicious/Plugin/Saml2/Controller/ErrorController.pm b/lib/Mojolicious/Plugin/Saml2/Controller/ErrorController.pm
deleted file mode 100644
index 1903a2c49e..0000000000
--- a/lib/Mojolicious/Plugin/Saml2/Controller/ErrorController.pm
+++ /dev/null
@@ -1,9 +0,0 @@
-package Mojolicious::Plugin::Saml2::Controller::ErrorController;
-
-use Mojo::Base 'Mojolicious::Controller', -signatures, -async_await;
-
-async sub get ($c) {
- return $c->reply->exception('SAML2 Login Error')->rendered(400);
-}
-
-1;
diff --git a/lib/Mojolicious/Plugin/Saml2/Controller/MetadataController.pm b/lib/Mojolicious/Plugin/Saml2/Controller/MetadataController.pm
deleted file mode 100644
index bd74129093..0000000000
--- a/lib/Mojolicious/Plugin/Saml2/Controller/MetadataController.pm
+++ /dev/null
@@ -1,10 +0,0 @@
-package Mojolicious::Plugin::Saml2::Controller::MetadataController;
-
-use Mojo::Base 'Mojolicious::Controller', -signatures, -async_await;
-
-async sub get ($c) {
- my $sp = $c->saml2->getSp();
- return $c->render(data => $sp->metadata(), format => 'xml');
-}
-
-1;
diff --git a/lib/Mojolicious/Plugin/Saml2/Exception.pm b/lib/Mojolicious/Plugin/Saml2/Exception.pm
deleted file mode 100644
index 43d5cd88e0..0000000000
--- a/lib/Mojolicious/Plugin/Saml2/Exception.pm
+++ /dev/null
@@ -1,3 +0,0 @@
-package Mojolicious::Plugin::Saml2::Exception;
-use Mojo::Base 'Mojo::Exception', -signatures;
-1;
diff --git a/lib/Mojolicious/Plugin/Saml2/README.md b/lib/Mojolicious/Plugin/Saml2/README.md
deleted file mode 100644
index f531c80ea9..0000000000
--- a/lib/Mojolicious/Plugin/Saml2/README.md
+++ /dev/null
@@ -1,163 +0,0 @@
-# SAML2 Authentication Plugin
-
-This Mojolicious plugin implements SAML2 authentication for Webwork. SAML2
-functionality is provided by the
-[Net::SAML2](https://metacpan.org/dist/Net-SAML2) library. Net::SAML2 claims to
-be compatible with a wide array of SAML2 based Single Sign On systems such as
-Shibboleth. This plugin is intended to replace the previous Shibboleth
-authentication module that depended on Apache mod_shib.
-
-There are two components to SAML2 support, the Mojolicious plugin here and a a
-regular Webwork Authen module at `lib/WeBWorK/Authen/Saml2.pm`.
-
-## Configuration
-
-To enable the Saml2 plugin, copy `conf/authen_saml2.dist.yml` to
-`conf/authen_saml2.yml`.
-
-Important settings:
-
-- *idp.metadata_url* - must be set to the IdP's metadata endpoint
-- *sp.entity_id* - the ID for the Webwork SP, this is usually the application
- root URL plus the base path to the SP
-- *sp.attributes* - list of attribute OIDs that the SP will look at and try to
- match to a Webwork username
-- *sp.cert*, *sp.signing_key* - a unique key and cert pair must be generated
- for your own prod deployments. The example key and cert is only meant for dev
- use as described below in [Docker Compose](#docker-compose-dev).
-
-The Saml2 plugin will generate its own xml metadata that can be used by the IdP
-for configuration. This is available at the `/saml2/metadata` URL with the
-default config. Endpoint locations, such as metadata, can be configured under
-`sp.route`.
-
-### Generate key and cert
-
-OpenSSL can be used to generate the key and cert, like the following command:
-
-```bash
-openssl req -newkey rsa:4096 -new -x509 -days 3652 -nodes -out saml.crt -keyout saml.pem
-```
-
-The cert is placed in `saml.crt`. The key is in `saml.pem`.
-
-### localOverrides.conf
-
-Webwork's authentication system will need to be configured to use the Saml2
-module in `conf/localOverrides.conf`. The example below allows bypassing the
-Saml2 module to use the internal username/password login as a fallback:
-
-```perl
-$authen{user_module} = [
- 'WeBWorK::Authen::Saml2',
- 'WeBWorK::Authen::Basic_TheLastOption'
-];
-```
-
-If you add the bypass query to a course url, the Saml2 module will be skipped
-and the next one in the list used, e.g.:
-`http://localhost:8080/webwork2/TEST100?bypassSaml2=1`
-
-Admin login also needs its own config, the example below assumes the bypass
-option is disabled:
-
-```perl
-$authen{admin_module} = [
- 'WeBWorK::Authen::Saml2'
-];
-```
-
-To disable the bypass, `conf/authen_saml2.yml` must also be edited, commenting
-out the `bypass_query` line.
-
-## Docker Compose Dev
-
-A dev use SAML2 IdP was added to docker-compose.yml.dist, to start this IdP
-along with the rest of the Webwork, add the '--profile saml2dev' arg to docker
-compose:
-
-```bash
-docker compose --profile saml2dev up
-```
-
-Without the profile arg, the IdP services do not start. The dev IdP is a
-SimpleSAMLphp instance.
-
-### Setup
-
-The default `conf/authen_saml2.dist.yml` is configured to use this dev IdP.
-Just copy it to `conf/authen_saml2.yml` and it should work.
-
-### Admin
-
-The dev IdP has an admin interface, you can login with the password 'admin' at:
-
-```text
-http://localhost:8180/simplesaml/module.php/admin/federation
-```
-
-The admin interface lets you check if the IdP has properly registered the
-Webwork SP under the 'Federation' tab, it should be listed under the "Trusted
-entities" section.
-
-You can also test login with the user accounts listed below in the "Test" tab
-under the "example-userpass" authentication source.
-
-### Users
-
-There are some single sign-on accounts preconfigured:
-
-- Username: student01
- - Password: student01
-- Username: instructor01
- - Password: instructor01
-- Username: staff01
- - Password: staff01
-
-You can add more accounts at `docker-config/idp/config/authsources.php` in the
-`example-userpass` section. The IdP image will need to be rebuilt for the
-change to take effect.
-
-## Troubleshooting
-
-### Webwork doesn't start, "Error retrieving metadata"
-
-This error message indicates that the Saml2 plugin wasn't able to grab metadata
-from the IdP metadata url. Make sure the IdP is accessible by the container.
-Example error message:
-
-```text
-app-1 | Can't load application from file "/opt/webwork/webwork2/bin/webwork2":
-Error retrieving metadata: Can't connect to idp.docker:8180 (Connection
-refused) (500)
-```
-
-### User not found in course
-
-The user was verified by the IdP but did not have a corresponding user account
-in the Webwork course. The Webwork user account needs to be created separately
-as the Saml2 plugin does not do user provisioning.
-
-### Logout shows uninitialized value warnings
-
-The message on the page reads "The course TEST100 uses an external
-authentication system ()."
-
-The external auth message takes values from LTI config. If you're not using
-LTI, you can define the missing values separately in `localOverrides.conf`:
-
-```perl
-$LTIVersion = 'v1p3';
-$LTI{v1p3}{LMS_name} = 'Webwork';
-$LTI{v1p3}{LMS_url} = 'http://localhost:8080/';
-```
-
-It's not an ideal solution but the Saml2 plugin needs to declare itself as an
-external auth system in order to avoid the internal 2FA. And the external auth
-message assumes LTI is on.
-
-### Dev IdP does not show the Webwork SP in Federation tab
-
-Webwork's first startup might be slow enough that the IdP wasn't able to
-successfully grab metadata from the Webwork Saml2 plugin. Restarting everything
-should fix this.
diff --git a/lib/Mojolicious/Plugin/Saml2/Router.pm b/lib/Mojolicious/Plugin/Saml2/Router.pm
deleted file mode 100644
index ac4dd5cb7b..0000000000
--- a/lib/Mojolicious/Plugin/Saml2/Router.pm
+++ /dev/null
@@ -1,17 +0,0 @@
-package Mojolicious::Plugin::Saml2::Router;
-
-use Mojo::Base -signatures;
-
-sub setup ($app, $conf) {
- my $subRouter =
- $app->routes->any($conf->{sp}{route}{base})->to(namespace => 'Mojolicious::Plugin::Saml2::Controller')
- ->name('saml2.base');
- $subRouter->get($conf->{sp}{route}{metadata})->to(controller => 'MetadataController', action => 'get')
- ->name('saml2.metadata');
- $subRouter->get($conf->{sp}{route}{error})->to(controller => 'ErrorController', action => 'get')
- ->name('saml2.error');
- $subRouter->post($conf->{sp}{route}{acs}{post})->to(controller => 'AcsPostController', action => 'post')
- ->name('saml2.acsPost');
-}
-
-1;
diff --git a/lib/Mojolicious/Plugin/Saml2/Saml2Plugin.pm b/lib/Mojolicious/Plugin/Saml2/Saml2Plugin.pm
deleted file mode 100644
index 4073520e1c..0000000000
--- a/lib/Mojolicious/Plugin/Saml2/Saml2Plugin.pm
+++ /dev/null
@@ -1,155 +0,0 @@
-package Mojolicious::Plugin::Saml2::Saml2Plugin;
-use Mojo::Base 'Mojolicious::Plugin', -signatures;
-# external libs
-use File::Temp qw/ tempfile /;
-use Mojo::JSON qw(encode_json);
-use Mojolicious;
-use Mojolicious::Plugin::NotYAMLConfig;
-use Net::SAML2::IdP;
-use Net::SAML2::SP;
-use URN::OASIS::SAML2 qw(BINDING_HTTP_POST BINDING_HTTP_REDIRECT);
-# external libs for NotYAMLConfig
-use CPAN::Meta::YAML;
-use Mojo::Util qw(decode encode);
-# webwork modules
-use WeBWorK::Debug qw(debug);
-# plugin's own modules
-use Mojolicious::Plugin::Saml2::Exception;
-use Mojolicious::Plugin::Saml2::Router;
-
-use constant Exception => 'Mojolicious::Plugin::Saml2::Exception';
-
-our $VERSION = '0.0.1';
-
-sub register ($self, $app, $conf = {}) {
- # yml config can be overridden with config passed in during plugin init
- $conf = $self->_loadConf($conf, $app);
- $self->checkConf($conf);
- # note this will grab the IdP metadata on every server reboot
- my $idp = Net::SAML2::IdP->new_from_url(url => $conf->{idp}{metadata_url});
- my $spCertFile = $self->_getTmpFileWithContent($conf->{sp}{cert});
- my $spSigningKeyFile = $self->_getTmpFileWithContent($conf->{sp}{signing_key});
- my $idpCertFile = $self->_getTmpFileWithContent($idp->cert('signing')->[0]);
- # setup routes for metadata and samlresponse handling
- Mojolicious::Plugin::Saml2::Router::setup($app, $conf);
- # cached values we need later
- $app->helper('saml2.getConf' => sub { return $conf; });
- $app->helper('saml2.getIdp' => sub { return $idp; });
- $app->helper('saml2.getSpCertFile' => sub { return $spCertFile; });
- $app->helper('saml2.getSpSigningKeyFile' => sub { return $spSigningKeyFile; });
- $app->helper('saml2.getIdpCertFile' => sub { return $idpCertFile; });
- $app->helper('saml2.getSp' => \&getSp);
- # called by the Webwork Saml2 authen module to redirect users to the IdP
- $app->helper('saml2.sendLoginRequest' => \&sendLoginRequest);
-}
-
-sub checkConf ($self, $conf) {
- if (!$conf->{idp}) {
- Exception->throw("Config missing 'idp' section");
- }
- if (!$conf->{idp}{metadata_url}) {
- Exception->throw("Config in 'idp' missing 'metadata_url'");
- }
- if (!$conf->{sp}) {
- Exception->throw("Config missing 'sp' section");
- }
- if (!$conf->{sp}{entity_id}) {
- Exception->throw("Config in 'sp' missing 'entity_id'");
- }
- if (!$conf->{sp}{cert}) {
- Exception->throw("Config in 'sp' missing 'cert'");
- }
- if (!$conf->{sp}{signing_key}) {
- Exception->throw("Config in 'sp' missing 'signing_key'");
- }
- if (!$conf->{sp}{route}) {
- Exception->throw("Config missing 'sp.route' section");
- }
- if (!$conf->{sp}{route}{base}) {
- Exception->throw("Config in 'sp.route' missing 'base'");
- }
- if (!$conf->{sp}{route}{metadata}) {
- Exception->throw("Config in 'sp.route' missing 'metadata'");
- }
- if (!$conf->{sp}{route}{acs}) {
- Exception->throw("Config missing 'sp.route.acs' section");
- }
- if (!$conf->{sp}{route}{acs}{post}) {
- Exception->throw("Config in 'sp.route.acs' missing 'post'");
- }
-}
-
-# we need an SP instance in order to generate the xml metadata and specify our
-# SP endpoints. We have to do this in a helper cause we need to use the
-# controller's url_for()
-sub getSp ($c) {
- state $sp;
- if ($sp) { return $sp; }
- my $conf = $c->saml2->getConf();
- $sp = Net::SAML2::SP->new(
- issuer => $conf->{sp}->{entity_id},
- # base url for SP services
- url => $ENV{WEBWORK_ROOT_URL} . $c->url_for('saml2.base'),
- error_url => $ENV{WEBWORK_ROOT_URL} . $c->url_for('saml2.error'),
- cert => $c->saml2->getSpCertFile(),
- key => $c->saml2->getSpSigningKeyFile(),
- org_contact => $conf->{sp}->{org}->{contact},
- org_name => $conf->{sp}->{org}->{name},
- org_url => $conf->{sp}->{org}->{url},
- org_display_name => $conf->{sp}->{org}->{display_name},
- assertion_consumer_service => [ {
- Binding => BINDING_HTTP_POST,
- Location => $ENV{WEBWORK_ROOT_URL} . $c->url_for('saml2.acsPost'),
- isDefault => 'true',
- } ]
- );
- return $sp;
-}
-
-# $returnUrl is the course URL that the user should be directed into after they
-# sucessfully authed at the IdP
-sub sendLoginRequest ($c, $returnUrl, $courseName) {
- debug('Creating Login Request');
- my $conf = $c->saml2->getConf();
- my $idp = $c->saml2->getIdp();
- my $sp = $c->saml2->getSp();
- my $authReq = $sp->authn_request($idp->sso_url(BINDING_HTTP_REDIRECT));
- $c->session->{authReqId} = $authReq->id;
- my $redirect = $sp->sso_redirect_binding($idp, 'SAMLRequest');
- # info the IdP relays back to help us put the user in the right place after
- # login
- my $relayState = {
- 'course' => $courseName,
- 'url' => $returnUrl
- };
- my $url = $redirect->sign($authReq->as_xml, encode_json($relayState));
- debug('Redirecting user to the IdP');
- $c->redirect_to($url);
-}
-
-# Write $content into a temporary file and return the full path to that file.
-# Net:SAML2 strangely won't take keys and certs as strings, it only wants
-# filepaths, this helper is meant to get around that.
-sub _getTmpFileWithContent ($self, $content) {
- my ($fh, $filename) = tempfile();
- print $fh $content;
- close($fh);
- return $filename;
-}
-
-sub _loadConf ($self, $pluginConf, $app) {
- my $confFile = "$ENV{WEBWORK_ROOT}/conf/authen_saml2.yml";
- if (!-e $confFile) {
- Exception->throw("Missing conf file: $confFile");
- }
- $app->config->{config_override} = 1;
- my $yamlPlugin = Mojolicious::Plugin::NotYAMLConfig->new;
- # we just want to use the plugin's load() method and don't want to merge
- # with the app config, so we have to manually do the setup done in
- # NotYAMLConfig's register()
- $yamlPlugin->{yaml} = sub { CPAN::Meta::YAML::Load(decode 'UTF-8', shift) };
- my $yamlConf = $yamlPlugin->load($confFile, {}, $app);
- return { %$yamlConf, %$pluginConf };
-}
-
-1;
diff --git a/lib/Mojolicious/WeBWorK.pm b/lib/Mojolicious/WeBWorK.pm
index ef3a1c8fc0..66281467f9 100644
--- a/lib/Mojolicious/WeBWorK.pm
+++ b/lib/Mojolicious/WeBWorK.pm
@@ -94,10 +94,6 @@ sub startup ($app) {
# Provide the ability to serve data as a file download.
$app->plugin('RenderFile');
- # Load the SAML2 plugin if configuration found
- if (-e "$ENV{WEBWORK_ROOT}/conf/authen_saml2.yml") {
- $app->plugin('Mojolicious::Plugin::Saml2::Saml2Plugin');
- }
# Helpers
diff --git a/lib/WeBWorK.pm b/lib/WeBWorK.pm
index 0ac5eeba66..319ea19c82 100644
--- a/lib/WeBWorK.pm
+++ b/lib/WeBWorK.pm
@@ -62,28 +62,19 @@ async sub dispatch ($c) {
# Note that this is Time::HiRes's time, which gives floating point values.
$c->submitTime(time);
- my $method = $c->req->method;
- my $location = $c->location;
- my $uri = $c->url_for;
- my $args = $c->req->params->to_string || '';
+ my $method = $c->req->method;
+ my $uri = $c->url_for;
+ my $args = $c->req->params->to_string || '';
debug("\n\n===> Begin " . __PACKAGE__ . "::dispatch() <===\n\n");
- debug("Hi, I'm the new dispatcher!\n");
debug(("-" x 80) . "\n");
- debug("Okay, I got some basic information:\n");
- debug("The site location is $location\n");
debug("The request method is $method\n");
debug("The URI is $uri\n");
debug("The argument string is $args\n");
debug(('-' x 80) . "\n");
- my ($path) = $uri =~ m/$location(.*)/;
- $path .= '/' if $path !~ m(/$);
- debug("The path is $path\n");
-
debug("The current route is " . $c->current_route . "\n");
- debug("Here is some information about this route:\n");
my $displayModule = ref $c;
my %routeCaptures = %{ $c->stash->{'mojo.captures'} };
@@ -96,8 +87,6 @@ async sub dispatch ($c) {
debug(('-' x 80) . "\n");
- debug("Now we want to look at the parameters we got.\n");
-
debug("The raw params:\n");
for my $key ($c->param) {
# Make it so we dont debug plain text passwords
@@ -122,7 +111,6 @@ async sub dispatch ($c) {
$c->initializeRoute(\%routeCaptures) if $c->can('initializeRoute');
# Create Course Environment
- debug("We need to get a course environment (with or without a courseID!)\n");
my $ce = eval { WeBWorK::CourseEnvironment->new({ courseName => $routeCaptures{courseID} }) };
$@ and die "Failed to initialize course environment: $@\n";
debug("Here's the course environment: $ce\n");
@@ -164,12 +152,14 @@ async sub dispatch ($c) {
if ($routeCaptures{courseID}) {
debug("We got a courseID from the route, now we can do some stuff:\n");
+ # This route could have the courseID set, but does not need authentication.
+ return 1 if $c->current_route eq 'saml2_metadata';
+
return (0, 'This course does not exist.')
unless (-e $ce->{courseDirs}{root}
|| -e "$ce->{webwork_courses_dir}/$ce->{admin_course_id}/archives/$routeCaptures{courseID}.tar.gz");
return (0, 'This course has been archived and closed.') unless -e $ce->{courseDirs}{root};
- debug("...we can create a database object...\n");
my $db = WeBWorK::DB->new($ce->{dbLayout});
debug("(here's the DB handle: $db)\n");
$c->db($db);
diff --git a/lib/WeBWorK/Authen/Saml2.pm b/lib/WeBWorK/Authen/Saml2.pm
index ef02074cc5..61add1cd60 100644
--- a/lib/WeBWorK/Authen/Saml2.pm
+++ b/lib/WeBWorK/Authen/Saml2.pm
@@ -16,87 +16,331 @@
package WeBWorK::Authen::Saml2;
use Mojo::Base 'WeBWorK::Authen', -signatures;
+use Mojo::File qw(path tempfile);
+use Mojo::JSON qw(encode_json);
+use Mojo::UserAgent;
+use Net::SAML2::IdP;
+use Net::SAML2::SP;
+use URN::OASIS::SAML2 qw(BINDING_HTTP_POST BINDING_HTTP_REDIRECT);
+use Net::SAML2::Binding::POST;
+use Net::SAML2::Protocol::Assertion;
+
use WeBWorK::Debug qw(debug);
+use WeBWorK::Authen::LTIAdvanced::Nonce;
=head1 NAME
-WeBWorK::Authen::Saml2 - Sends everyone to the SAML2 IdP to authenticate.
-
-Requires the Saml2 plugin to be loaded and configured.
+WeBWorK::Authen::Saml2 - Authenticate using a SAML2 identity provider.
=cut
sub request_has_data_for_this_verification_module ($self) {
my $c = $self->{c};
- $self->setIsLoggedIn(0);
- # skip if Saml2 plugin config is missing, this means the plugin isn't loaded
- if (!-e "$ENV{WEBWORK_ROOT}/conf/authen_saml2.yml") {
- debug('Saml2 Authen Module requires Saml2 plugin to be configured');
+ # Skip if the bypass_query param is set.
+ if ($c->ce->{saml2}{bypass_query} && $c->param($c->ce->{saml2}{bypass_query})) {
+ debug('Saml2 authen module bypass detected. Going to next authentication module.');
return 0;
}
- # skip if we have the param that indicates we want to bypass SAML2
- my $bypassQuery = $c->saml2->getConf->{bypass_query};
- if ($bypassQuery && $c->param($bypassQuery)) {
- debug('Saml2 Authen module bypass detected, going to next module');
+
+ return 1;
+}
+
+sub verify ($self) {
+ my $result = $self->SUPER::verify;
+ my $c = $self->{c};
+
+ if ($c->current_route eq 'saml2_acs') {
+ # Transfer the saml2_nameid and saml2_session to the webwork session.
+ # These are used to logout of the identity provider if that is configured.
+ $self->session->{saml2_nameid} = $c->stash->{saml2_nameid} if $c->stash->{saml2_nameid};
+ $self->session->{saml2_session} = $c->stash->{saml2_session} if $c->stash->{saml2_session};
+
+ # If two factor verification is needed, defer that until after redirecting to the course route.
+ if ($c->stash->{saml2_redirect} && $self->session->{two_factor_verification_needed}) {
+ $self->session->{two_factor_verification_needed_after_redirect} =
+ delete $self->session->{two_factor_verification_needed};
+ return 1;
+ }
+ }
+
+ return $result;
+}
+
+sub do_verify ($self) {
+ my $c = $self->{c};
+ my $ce = $c->ce;
+
+ if ($c->current_route eq 'saml2_acs') {
+ debug('Verifying Saml2 assertion');
+
+ my $idpCertificateFile = $self->idp(1);
+ unless ($idpCertificateFile) {
+ $c->stash->{authen_error} = $c->maketext(
+ 'An internal server error occured. Please contact the system administrator for assistance.');
+ return 0;
+ }
+
+ # Verify that the response is signed by the identity provider and decode it.
+ my $decodedXml = Net::SAML2::Binding::POST->new(cacert => $idpCertificateFile->to_string)
+ ->handle_response($c->stash->{saml2}{samlResp});
+ my $assertion = Net::SAML2::Protocol::Assertion->new_from_xml(
+ xml => $decodedXml,
+ key_file => $self->spKeyFile->to_string
+ );
+
+ # Get the database key containing the authReqId that was generated before redirecting to the identity provider.
+ my $authReqIdKey = $c->db->getKey($assertion->in_response_to);
+ unless ($authReqIdKey) {
+ $c->stash->{authen_error} = $c->maketext('Invalid user ID or password.');
+ debug('Invalid request id in response. Possible CSFR.');
+ return 0;
+ }
+ eval { $c->db->deleteKey($authReqIdKey->user_id) }; # Delete the key to avoid replay.
+
+ # Verify that the response has the same authReqId which means it's responding to the authentication request
+ # generated by webwork2. This also checks that timestamps are valid.
+ my $valid = $assertion->valid($ce->{saml2}{sp}{entity_id}, $authReqIdKey->user_id);
+ unless ($valid) {
+ $c->stash->{authen_error} = $c->maketext('Invalid user ID or password.');
+ debug('Bad timestamp or issuer');
+ return 0;
+ }
+
+ debug('Got valid response and looking for username.');
+ my $userId = $self->getUserId($ce->{saml2}{sp}{attributes}, $assertion);
+ if ($userId) {
+ debug("Got username $userId");
+
+ $c->authen->{saml2UserId} = $userId;
+ if ($self->SUPER::do_verify) {
+ # The user and key need to be set before systemLink is called. They are only used if
+ # $session_management_via is 'key'.
+ $c->param('user', $userId);
+ $c->param('key', $self->{session_key});
+ $c->stash->{saml2_redirect} = $c->systemLink($c->url_for($c->stash->{saml2}{relayState}{url}));
+
+ # Save these in the stash for now. They will be transfered to the session after it has been created.
+ $c->stash->{saml2_nameid} = $assertion->nameid;
+ $c->stash->{saml2_session} = $assertion->{session};
+
+ return 1;
+ }
+ }
+ $c->stash->{authen_error} = $c->maketext('User not found in course.');
+ debug('Unauthorized - User not found in ' . $c->stash->{courseID});
return 0;
}
- # handle as existing session if we have cookie or if it's a rpc
- my ($cookieUser, $cookieKey, $cookieTimeStamp) = $self->fetchCookie;
- if (defined $cookieUser || defined $c->{rpc}) {
- $self->setIsLoggedIn(1);
+
+ # If there is an existing session, then control will be passed to the authen base class.
+ if ($ce->{session_management_via} eq 'session_cookie') {
+ my ($cookieUser) = $self->fetchCookie;
+ $self->{isLoggedIn} = 1 if defined $cookieUser;
+ } elsif ($c->param('user')) {
+ my $key = $c->db->getKey($c->param('user'));
+ $self->{isLoggedIn} = 1 if $key;
}
- return 1;
+ if ($self->{isLoggedIn}) {
+ debug('User signed in or was previously signed in. Saml2 passing control back to the authen base class.');
+
+ # There was a successful saml response or the user was already logged in.
+ # So hand off to the authen base class to verify the user and manage the session.
+ my $result = $self->SUPER::do_verify;
+
+ $self->session->{two_factor_verification_needed} =
+ delete $self->session->{two_factor_verification_needed_after_redirect}
+ if $self->session->{two_factor_verification_needed_after_redirect};
+
+ return $result;
+ }
+
+ # This occurs if the user clicks the logout button when the identity provider session has timed out, but the
+ # webwork2 session is still active. In this case return 0 so that the logged out page is shown anyway.
+ return 0 if $c->current_route eq 'logout';
+
+ # The user doesn't have an existing session, so redirect to the identity provider for login.
+ $self->sendLoginRequest;
+
+ return 0;
}
-sub do_verify ($self) {
- if ($self->{saml2UserId} || $self->{isLoggedIn}) {
- # successful saml response/already logged in, hand off to the parent
- # to create/read the session
- $self->{external_auth} = 1; # so we skip internal 2fa
- return $self->SUPER::do_verify();
+sub sp ($self) {
+ my $c = $self->{c};
+ return $c->stash->{sp} if $c->stash->{sp};
+
+ my $ce = $c->ce;
+
+ my $spCertificateFile = path($ce->{saml2}{sp}{certificate_file});
+ $spCertificateFile = $c->app->home->child($spCertificateFile) unless $spCertificateFile->is_abs;
+
+ $c->stash->{sp} = Net::SAML2::SP->new(
+ issuer => $ce->{saml2}{sp}{entity_id},
+ url => $ce->{server_root_url} . $c->url_for('root'),
+ error_url => $ce->{server_root_url} . $c->url_for('saml2_error'),
+ cert => $spCertificateFile->to_string,
+ key => $self->spKeyFile->to_string,
+ org_contact => $ce->{saml2}{sp}{org}{contact},
+ org_name => $ce->{saml2}{sp}{org}{name},
+ org_url => $ce->{saml2}{sp}{org}{url},
+ org_display_name => $ce->{saml2}{sp}{org}{display_name},
+ assertion_consumer_service => [ {
+ Binding => BINDING_HTTP_POST,
+ Location => $ce->{server_root_url} . $c->url_for('saml2_acs'),
+ isDefault => 'true',
+ } ],
+ $ce->{saml2}{sp}{enable_sp_initiated_logout}
+ ? (
+ single_logout_service => [ {
+ Binding => BINDING_HTTP_POST,
+ Location => $ce->{server_root_url} . $c->url_for('saml2_logout')
+ } ]
+ )
+ : ()
+ );
+
+ return $c->stash->{sp};
+}
+
+# The first time this method is executed for a given identity provider, the metadata file is retrieved from the metadata
+# URL. It is then saved in the the $ce->{saml2}{active_idp} subdirectory of $ce->{webworkDirs}{DATA}/Saml2IDPs together
+# with the identity provider's signing key which is extracted from the retrieved metadata. On later requests the
+# metadata and certificate are used from the saved files. This prevents the need to retrieve the metadata on every
+# login request.
+sub idp ($self, $ceritificateOnly = 0) {
+ if (!$self->{idp_certificate_file} || !$self->{idp}) {
+ my $ce = $self->{c}->ce;
+
+ my $saml2IDPDir = path("$ce->{webworkDirs}{DATA}/Saml2IDPs")->child($ce->{saml2}{active_idp});
+ $saml2IDPDir->make_path;
+
+ my $metadataXMLFile = $saml2IDPDir->child('metadata.xml');
+ my $certificateFile = $saml2IDPDir->child('cacert.crt');
+
+ if (-r $metadataXMLFile && -r $certificateFile) {
+ $self->{idp} =
+ Net::SAML2::IdP->new_from_xml(xml => $metadataXMLFile->slurp, cacert => $certificateFile->to_string);
+ $self->{idp_certificate_file} = $certificateFile;
+ } else {
+ my $response = Mojo::UserAgent->new->get($ce->{saml2}{idps}{ $ce->{saml2}{active_idp} })->result;
+ if ($response->is_success) {
+ my $metadataXML = $response->body;
+ $metadataXMLFile->spew($metadataXML);
+ $self->{idp} = Net::SAML2::IdP->new_from_xml(xml => $metadataXML);
+ $certificateFile->spew($self->{idp}->cert('signing')->[0]);
+ $self->{idp_certificate_file} = $certificateFile;
+ } else {
+ debug("Unable to retrieve metadata from identity provider $ce->{saml2}{active_idp} with "
+ . "metadata URL $ce->{samle}{idps}{$ce->{saml2}{active_idp}}");
+ }
+ }
}
- # user doesn't have an existing session, send them to IdP for login
+
+ return $self->{idp_certificate_file} if $ceritificateOnly;
+ return $self->{idp};
+}
+
+sub spKeyFile ($self) {
+ my $c = $self->{c};
+ return $self->{spKeyFile} if $self->{spKeyFile};
+ $self->{spKeyFile} = path($c->ce->{saml2}{sp}{private_key_file});
+ $self->{spKeyFile} = $c->app->home->child($self->{spKeyFile}) unless $self->{spKeyFile}->is_abs;
+ return $self->{spKeyFile};
+}
+
+sub sendLoginRequest ($self) {
my $c = $self->{c};
- my $ce = $c->{ce};
- debug('User needs to go to the IdP for login');
- debug('If login successful, user should be in course: ' . $ce->{courseName});
- debug('With the URL ' . $c->req->url);
- $c->saml2->sendLoginRequest($c->req->url->to_string, $ce->{courseName});
-
- # we fail verify for this request but doesn't matter cause the user gets
- # redirected to the IdP
- return 0;
+ my $ce = $c->ce;
+
+ my $idp = $self->idp;
+ unless ($idp) {
+ $c->stash->{authen_error} =
+ $c->maketext('An internal server error occured. Please contact the system administrator for assistance.');
+ return 0;
+ }
+
+ my $authReq = $self->sp->authn_request($idp->sso_url(BINDING_HTTP_REDIRECT));
+
+ # Get rid of stale request ids in the database. This borrows the maybe_purge_nonces method from the
+ # WeBWorK::Authen::LTIAdvanced::Nonce package.
+ WeBWorK::Authen::LTIAdvanced::Nonce->new($c, '', 0)->maybe_purge_nonces;
+
+ # The request id needs to be stored so that it can be verified in the identity provider response.
+ # This uses the "nonce" hack to store the request id in the key table.
+ my $key = $c->db->newKey({ user_id => $authReq->id, timestamp => time, key => 'nonce' });
+ eval { $c->db->deleteKey($authReq->id) };
+ eval { $c->db->addKey($key) };
+
+ # The second argument of the sign method contains info that the identity provider relays back.
+ # This information is used to send the user to the right place after login.
+ debug('Redirecting user to the identity provider');
+ $self->{redirect} = $self->sp->sso_redirect_binding($idp, 'SAMLRequest')
+ ->sign($authReq->as_xml, encode_json({ course => $ce->{courseName}, url => $c->req->url->to_string }));
+ return;
+}
+
+sub logout_user ($self) {
+ my $ce = $self->{c}->ce;
+ if ($ce->{saml2}{sp}{enable_sp_initiated_logout}
+ && defined $self->session->{saml2_nameid}
+ && defined $self->session->{saml2_session})
+ {
+ my $idp = $self->idp;
+ return unless $idp;
+
+ my $logoutReq = $self->sp->logout_request(
+ $idp->slo_url(BINDING_HTTP_REDIRECT), $self->session->{saml2_nameid},
+ $idp->format || undef, $self->session->{saml2_session}
+ );
+
+ debug('Redirecting user to the identity provider for logout');
+ $self->{redirect} = $self->sp->slo_redirect_binding($idp, 'SAMLRequest')
+ ->sign($logoutReq->as_xml, encode_json({ course => $ce->{courseName} }));
+ }
+ return;
+}
+
+sub getUserId ($self, $attributeKeys, $assertion) {
+ my $ce = $self->{c}->ce;
+ my $db = $self->{c}->db;
+
+ if ($attributeKeys) {
+ for my $key (@$attributeKeys) {
+ debug("Trying attribute $key for username");
+ my $possibleUserId = $assertion->attributes->{$key}[0];
+ next unless $possibleUserId;
+ if ($db->getUser($possibleUserId)) {
+ debug("Using attribute value for username: $possibleUserId");
+ return $possibleUserId;
+ }
+ }
+ }
+ debug('No username match in attributes. Trying NameID fallback');
+ if ($db->getUser($assertion->nameid)) {
+ debug('Using NameID for username: ' . $assertion->nameid);
+ return $assertion->nameid;
+ }
+ debug('NameID fallback failed. No username found.');
+ return;
}
sub get_credentials ($self) {
if ($self->{saml2UserId}) {
- # user has been authed by the IdP
+ # User has been authenticated with the identity provider.
$self->{user_id} = $self->{saml2UserId};
- $self->{login_type} = "normal";
- $self->{credential_source} = "SAML2";
- $self->{session_key} = undef;
+ $self->{login_type} = 'normal';
+ $self->{credential_source} = 'SAML2';
$self->{initial_login} = 1;
+ debug('credential source: "SAML2", user: "', $self->{user_id}, '"');
return 1;
}
- if ($self->{isLoggedIn}) {
- return $self->SUPER::get_credentials();
- }
+ return $self->SUPER::get_credentials if $self->{isLoggedIn};
return 0;
}
sub authenticate ($self) {
- # idp has authenticated us, so we can just return 1
+ # The identity provider handles authentication, so just return 1.
return 1;
}
-sub setSaml2UserId ($self, $userId) {
- $self->{saml2UserId} = $userId;
-}
-
-sub setIsLoggedIn ($self, $val) {
- $self->{isLoggedIn} = $val;
-}
-
1;
diff --git a/lib/WeBWorK/ContentGenerator/Logout.pm b/lib/WeBWorK/ContentGenerator/Logout.pm
index 60629ffcb6..67919656e2 100644
--- a/lib/WeBWorK/ContentGenerator/Logout.pm
+++ b/lib/WeBWorK/ContentGenerator/Logout.pm
@@ -30,7 +30,9 @@ sub pre_header_initialize ($c) {
my $db = $c->db;
my $authen = $c->authen;
- my $userID = $c->param('user_id');
+ # Do any special processing needed by external authentication. This is done before
+ # the session is killed in case the authentication module needs access to it.
+ $authen->logout_user if $authen->can('logout_user');
$authen->killSession;
$authen->WeBWorK::Authen::write_log_entry('LOGGED OUT');
@@ -39,6 +41,8 @@ sub pre_header_initialize ($c) {
# a proctored test. So try and delete the key.
my $proctorID = $c->param('proctor_user');
if ($proctorID) {
+ my $userID = $c->param('user_id');
+
eval { $db->deleteKey("$userID,$proctorID"); };
if ($@) {
$c->addbadmessage("Error when clearing proctor key: $@");
@@ -50,9 +54,6 @@ sub pre_header_initialize ($c) {
}
}
- # Do any special processing needed by external authentication.
- $authen->logout_user if $authen->can('logout_user');
-
$c->reply_with_redirect($authen->{redirect}) if $authen->{redirect};
return;
diff --git a/lib/WeBWorK/ContentGenerator/Saml2.pm b/lib/WeBWorK/ContentGenerator/Saml2.pm
new file mode 100644
index 0000000000..bda1b4aaac
--- /dev/null
+++ b/lib/WeBWorK/ContentGenerator/Saml2.pm
@@ -0,0 +1,60 @@
+################################################################################
+# WeBWorK Online Homework Delivery System
+# Copyright © 2000-2024 The WeBWorK Project, https://github.com/openwebwork
+#
+# This program is free software; you can redistribute it and/or modify it under
+# the terms of either: (a) the GNU General Public License as published by the
+# Free Software Foundation; either version 2, or (at your option) any later
+# version, or (b) the "Artistic License" which comes with this package.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the
+# Artistic License for more details.
+################################################################################
+
+package WeBWorK::ContentGenerator::Saml2;
+use Mojo::Base 'WeBWorK::ContentGenerator', -signatures;
+
+use Mojo::JSON qw(decode_json);
+
+use WeBWorK::Debug qw(debug);
+
+sub initializeRoute ($c, $routeCaptures) {
+ if ($c->current_route eq 'saml2_acs') {
+ return unless $c->param('SAMLResponse') && $c->param('RelayState');
+ $c->stash->{saml2}{relayState} = decode_json($c->param('RelayState'));
+ $c->stash->{saml2}{samlResp} = $c->param('SAMLResponse');
+ $routeCaptures->{courseID} = $c->stash->{courseID} = $c->stash->{saml2}{relayState}{course};
+ }
+
+ $routeCaptures->{courseID} = $c->stash->{courseID} = $c->param('courseID')
+ if $c->current_route eq 'saml2_metadata' && $c->param('courseID');
+
+ return;
+}
+
+sub assertionConsumerService ($c) {
+ debug('Authentication succeeded. Redirecting to ' . $c->stash->{saml2_redirect});
+ return $c->redirect_to($c->stash->{saml2_redirect});
+}
+
+sub metadata ($c) {
+ return $c->render(data => 'Internal site configuration error', status => 500) unless $c->authen->can('sp');
+ return $c->render(data => $c->authen->sp->metadata, format => 'xml');
+}
+
+sub errorResponse ($c) {
+ return $c->reply->exception('SAML2 Login Error')->rendered(400);
+}
+
+# When this request comes in the user is actually already signed out of webwork, so this just attempts to redirect back
+# to webwork's logout page for the course. This doesn't verify anything in the response from the identity provider, but
+# hopefully the courseID is found in the relay state so that the user can be redirected to the logout page for the
+# course.
+sub logout ($c) {
+ return $c->render('SAML2 Logout Error', status => 500) unless $c->param('RelayState');
+ return $c->redirect_to($c->url_for('logout', courseID => decode_json($c->param('RelayState'))->{course}));
+}
+
+1;
diff --git a/lib/WeBWorK/Utils/Routes.pm b/lib/WeBWorK/Utils/Routes.pm
index 3197229372..b95b07ae05 100644
--- a/lib/WeBWorK/Utils/Routes.pm
+++ b/lib/WeBWorK/Utils/Routes.pm
@@ -39,6 +39,11 @@ PLEASE FOR THE LOVE OF GOD UPDATE THIS IF YOU CHANGE THE ROUTES BELOW!!!
ltiadvantage_keys /ltiadvantage/keys
ltiadvantage_content_selection /ltiadvantage/content_selection
+ saml2_acs /saml2/acs
+ saml2_metadata /saml2/metadata
+ saml2_error /saml2/error
+ saml2_logout /saml2/logout
+
pod_index /pod
pod_viewer /pod/$filePath
@@ -155,6 +160,10 @@ my %routeParameters = (
ltiadvantage_launch
ltiadvantage_keys
ltiadvantage_content_selection
+ saml2_acs
+ saml2_metadata
+ saml2_error
+ saml2_logout
pod_index
sample_problem_index
set_list
@@ -222,6 +231,32 @@ my %routeParameters = (
action => 'content_selection'
},
+ # This route also ends up at the login screen on failure, and the title is not used anywhere else.
+ saml2_acs => {
+ title => x('Login'),
+ module => 'Saml2',
+ path => '/saml2/acs',
+ action => 'assertionConsumerService'
+ },
+ saml2_metadata => {
+ title => 'metadata',
+ module => 'Saml2',
+ path => '/saml2/metadata',
+ action => 'metadata'
+ },
+ saml2_error => {
+ title => 'error',
+ module => 'Saml2',
+ path => '/saml2/error',
+ action => 'errorResponse'
+ },
+ saml2_logout => {
+ title => 'logout',
+ module => 'Saml2',
+ path => '/saml2/logout',
+ action => 'logout'
+ },
+
pod_index => {
title => x('POD Index'),
children => [qw(pod_viewer)],
diff --git a/templates/ContentGenerator/Login.html.ep b/templates/ContentGenerator/Login.html.ep
index 0436697cd5..9031aacee5 100644
--- a/templates/ContentGenerator/Login.html.ep
+++ b/templates/ContentGenerator/Login.html.ep
@@ -6,7 +6,7 @@
%
% my $course = (stash('courseID') // '') =~ s/_/ /gr;
%
-% if ($externalAuth) {
+% if ($ce->{LTI} && $externalAuth) {
% my $LMS = $ce->{LTI}{ $ce->{LTIVersion} }{LMS_url}
% ? link_to($ce->{LTI}{ $ce->{LTIVersion} }{LMS_name} => $ce->{LTI}{ $ce->{LTIVersion} }{LMS_url})
% : $ce->{LTI}{ $ce->{LTIVersion} }{LMS_name};
@@ -24,6 +24,13 @@
tag('strong', $course), $LMS) =%>
% }
+% } elsif ($externalAuth) {
+ % if (stash('authen_error')) {
+
+ <%== maketext(q{This course uses an external authentication system. You've authenticated }
+ . q{through that system, but aren't allowed to log in to this course.}) =%>
+
+ % }
% } else {
<%== maketext('Please enter your username and password for [_1] below:', tag('b', $course)) %>
%
diff --git a/templates/ContentGenerator/Logout.html.ep b/templates/ContentGenerator/Logout.html.ep
index 59c19fb283..ed4d9fd928 100644
--- a/templates/ContentGenerator/Logout.html.ep
+++ b/templates/ContentGenerator/Logout.html.ep
@@ -1,7 +1,7 @@
<%= maketext('You have been logged out of WeBWorK.') =%>
%
% # This should be set in the course environment when a sequence of authentication modules is used.
-% if ($ce->{external_auth} || $authen->{external_auth}) {
+% if ($ce->{LTI} && ($ce->{external_auth} || $authen->{external_auth})) {
<%== maketext(
'The course [_1] uses an external authentication system ([_2]). Please go there to log in again.',
@@ -12,6 +12,9 @@
: $ce->{LTI}{ $ce->{LTIVersion} }{LMS_name}
) =%>
+% } elsif ($ce->{external_auth} || $authen->{external_auth}) {
+ <%== maketext('This course uses an external authentication system. '
+ . 'Please return to its sign in page to log in again.') =%>
% } else {
<%= form_for 'set_list', method => 'POST', begin =%>
<%= hidden_field force_passwd_authen => 1 =%>