From e5e0fd80f5151019e11a4c35fde2f4a9e294c03f Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Sun, 25 Aug 2024 06:34:37 -0500 Subject: [PATCH] Remove the Saml2 plugin. Unfortunately, that approach is too limited. --- conf/authen_saml2.conf.dist | 103 +++++++++++ conf/authen_saml2.dist.yml | 77 -------- conf/localOverrides.conf.dist | 10 ++ docker-config/docker-compose.dist.yml | 13 +- docker-config/idp/README.md | 84 +++++++++ lib/Mojolicious/Plugin/Saml2.pm | 138 -------------- lib/Mojolicious/Plugin/Saml2/Exception.pm | 18 -- lib/Mojolicious/Plugin/Saml2/README.md | 170 ------------------ lib/Mojolicious/Plugin/Saml2/Router.pm | 37 ---- lib/Mojolicious/WeBWorK.pm | 3 - lib/WeBWorK.pm | 3 + lib/WeBWorK/Authen/Saml2.pm | 136 ++++++++++---- .../ContentGenerator/Saml2.pm} | 7 +- lib/WeBWorK/Utils/Routes.pm | 59 +++--- 14 files changed, 351 insertions(+), 507 deletions(-) create mode 100644 conf/authen_saml2.conf.dist delete mode 100644 conf/authen_saml2.dist.yml create mode 100644 docker-config/idp/README.md delete mode 100644 lib/Mojolicious/Plugin/Saml2.pm delete mode 100644 lib/Mojolicious/Plugin/Saml2/Exception.pm delete mode 100644 lib/Mojolicious/Plugin/Saml2/README.md delete mode 100644 lib/Mojolicious/Plugin/Saml2/Router.pm rename lib/{Mojolicious/Plugin/Saml2/Controller/ACS.pm => WeBWorK/ContentGenerator/Saml2.pm} (85%) diff --git a/conf/authen_saml2.conf.dist b/conf/authen_saml2.conf.dist new file mode 100644 index 0000000000..386d29caef --- /dev/null +++ b/conf/authen_saml2.conf.dist @@ -0,0 +1,103 @@ +#!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; + +# Set this to the URL that serves the SAML2 metadata xml for the identity +# provider. Webwork will request the identity provider's metadata from this URL +# during the authentication process. +$saml2{idp}{metadata_url} = 'http://idp/simplesaml/module.php/saml/idp/metadata'; + +# 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 +# /webwork2/saml2/metadata URL if Saml2 authentication is enabled site wide. +# However, Saml2 authentication can be enabled for individual courses as well by +# setting the options in this file in a course's course.conf file. In that case +# the identity provider will need to be configured to obtain the metadata from +# the webwork2/courseID/saml2/metadata URL instead. Note that if Saml2 +# authentication is not enabled site wide and multiple courses use the same +# identity provider, then pick the courseID of one of the courses that is +# configured for that identity provider to use for 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 private +# certificate and public key for the webwork2 service provider. A certificate +# and 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 private +# "certificate" and the "public_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}{public_key_file} = 'docker-config/idp/certs/saml.pem'; + +############################################################################## +# SECURITY WARNING +# For production, you MUST provide your own unique 'certificate' and +# 'public_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. +############################################################################## + +1; diff --git a/conf/authen_saml2.dist.yml b/conf/authen_saml2.dist.yml deleted file mode 100644 index 9793f764f2..0000000000 --- a/conf/authen_saml2.dist.yml +++ /dev/null @@ -1,77 +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. -################################################################################ - -# This URL query parameter can be added to the end of a course url to skip the -# saml2 authen module and go to the next one. Comment out the next line to -# disable this feature. -bypass_query: bypassSaml2 - -# Settings for the central SAML2 server -idp: - # URL that serves the SAML2 metadata xml for the IdP - metadata_url: http://idp/simplesaml/module.php/saml/idp/metadata - -# Settings for webwork2 -sp: - # The entity_id is also known as the iss (issuer). - entity_id: http://localhost:8080/saml2 - - # Endpoints used by the plugin. They are all relative to the webwork root url - route: - # Path prefix for all URLs handled by the Saml2 plugin. - base: /saml2 - # Metadata path relative to the base route (so the actual path would be - # /saml2/metadata if the base route above is /saml2) - metadata: metadata - # The 'Assertion Consumer Service' route. This route basically handles the - # SAML response. The plugin only supports a POST request (HTTP POST - # Binding) for this route. - acs: acs - # Route for error responses from the consumer service. - error: error - - # 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 - # the org info contains the 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 webwork2 will 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 provide your own unique 'certificate' and - # 'private_key' files. The files in the default settings below are only - # intended to be used in development, and are publicly exposed. Hence, they - # provide NO SECURITY. - ############################################################################## - # You will usually need to use the certificate and private key provided by the - # maintainers of your identity provider instance. However, if you are - # maintaining your own identity provide instance 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. - # Place the certificates in a secure location and set certificate_file and - # private_key_file to the location of those files. 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. - certificate_file: docker-config/idp/certs/saml.crt - private_key_file: docker-config/idp/certs/saml.pem 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 7f51bbb4b1..6b937a0e14 100644 --- a/docker-config/docker-compose.dist.yml +++ b/docker-config/docker-compose.dist.yml @@ -251,19 +251,20 @@ services: #ports: # - "6311:6311" - # Saml2 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. + # 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:8080/saml2/metadata' - # The healthcheck url is simplesamlphp's url for triggering cron jobs. The - # cron job it will trigger will 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 will trigger will + # automatically fetch the webwork2 service provider's metadata. healthcheck: test: ['CMD', 'curl', '-f', 'http://localhost/simplesaml/module.php/cron/run/metarefresh/webwork2'] start_period: 1m diff --git a/docker-config/idp/README.md b/docker-config/idp/README.md new file mode 100644 index 0000000000..c2355bb438 --- /dev/null +++ b/docker-config/idp/README.md @@ -0,0 +1,84 @@ +# Development identity provider test instance for SAML2 authentication + +A development SAML2 identity provider is provided that uses SimpleSAMLphp. +Instructions for utilizing this instance follow. + +## Development IdP test instance with docker + +A docker service the 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 the docker services with + +```bash +docker compose --profile saml2dev down +``` + +## Development IdP test instance without docker + +Effective development is not done in a docker container. So it is usually more +useful to set up an identity provider without docker. + +TODO: Add these instructions. + +## Setup + +The default `conf/authen_saml2.conf.dist` is configured to use the docker +identity provider. Copy it to `conf/authen_saml2.conf` and it should work. + +TODO: Add instructions for setting up without docker. + +## 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/lib/Mojolicious/Plugin/Saml2.pm b/lib/Mojolicious/Plugin/Saml2.pm deleted file mode 100644 index 662d0e8ed8..0000000000 --- a/lib/Mojolicious/Plugin/Saml2.pm +++ /dev/null @@ -1,138 +0,0 @@ -################################################################################ -# 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 Mojolicious::Plugin::Saml2; -use Mojo::Base 'Mojolicious::Plugin', -signatures; - -use Mojo::JSON qw(encode_json); -use Mojo::File qw(path tempfile); -use YAML::XS qw(LoadFile); -use Net::SAML2::IdP; -use Net::SAML2::SP; -use URN::OASIS::SAML2 qw(BINDING_HTTP_POST BINDING_HTTP_REDIRECT); - -use Mojolicious::Plugin::Saml2::Exception; -use Mojolicious::Plugin::Saml2::Router; -use WeBWorK::Debug qw(debug); -use WeBWorK::Authen::LTIAdvanced::Nonce; - -use constant Exception => 'Mojolicious::Plugin::Saml2::Exception'; - -our $VERSION = '0.0.1'; - -sub register ($plugin, $app, $conf = {}) { - # The yml configuration can be overridden with the configuration passed in during plugin initialization. - $conf = $plugin->loadConf($conf, $app); - $plugin->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 $spCertificateFile = path($conf->{sp}{certificate_file}); - $spCertificateFile = $app->home->child($spCertificateFile) unless $spCertificateFile->is_abs; - my $spKeyFile = path($conf->{sp}{private_key_file}); - $spKeyFile = $app->home->child($spKeyFile) unless $spKeyFile->is_abs; - - my $idpCertificateFile = tempfile('idpCert_XXXXXXXX', DIR => $app->home->child('DATA')); - $idpCertificateFile->spew($idp->cert('signing')->[0]); - - # Setup routes for metadata and saml response handling. - Mojolicious::Plugin::Saml2::Router::setup($app, $conf); - - # Set cached values that will be needed later. - $app->helper('saml2.config' => sub { return $conf; }); - $app->helper('saml2.idp' => sub { return $idp; }); - $app->helper('saml2.spCertificateFile' => sub { return $spCertificateFile; }); - $app->helper('saml2.spKeyFile' => sub { return $spKeyFile; }); - $app->helper('saml2.idpCertificateFile' => sub { return $idpCertificateFile; }); - $app->helper('saml2.sp' => \&getSp); - - # This is called by the Webwork Saml2 authen module to redirect users to the IdP. - $app->helper('saml2.sendLoginRequest' => \&sendLoginRequest); - return; -} - -sub checkConf ($plugin, $conf) { - Exception->throw('Saml2 config missing "idp" section') unless $conf->{idp}; - Exception->throw('Saml2 config "idp" section missing "metadata_url"') unless $conf->{idp}{metadata_url}; - Exception->throw('Saml2 config missing "sp" section') unless $conf->{sp}; - Exception->throw('Saml2 config "sp" section missing "entity_id"') unless $conf->{sp}{entity_id}; - Exception->throw('Saml2 config "sp" section missing "certificate_file"') unless $conf->{sp}{certificate_file}; - Exception->throw('Saml2 config "sp" section missing "private_key_file"') unless $conf->{sp}{private_key_file}; - Exception->throw('Saml2 config "sp" section missing "route" sub-section') unless $conf->{sp}{route}; - Exception->throw('Saml2 config "sp.route" section missing "base"') unless $conf->{sp}{route}{base}; - Exception->throw('Saml2 config "sp.route" section missing "metadata"') unless $conf->{sp}{route}{metadata}; - Exception->throw('Saml2 config "sp.route" section missing "acs"') unless $conf->{sp}{route}{acs}; - Exception->throw('Saml2 config "sp.route" section missing "error"') unless $conf->{sp}{route}{error}; - return; -} - -# An SP instance is needed in order to generate the xml metadata and specify the SP endpoints. -# This needs to be done in a helper because the controller's url_for method must be used. -sub getSp ($c) { - state $sp; - if ($sp) { return $sp; } - my $conf = $c->saml2->config; - $sp = Net::SAML2::SP->new( - issuer => $conf->{sp}{entity_id}, - url => $c->ce->{server_root_url} . $c->url_for('saml2.base'), # base url for SP services - error_url => $c->ce->{server_root_url} . $c->url_for('saml2.error'), - cert => $c->saml2->spCertificateFile->to_string, - key => $c->saml2->spKeyFile->to_string, - 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 => $c->ce->{server_root_url} . $c->url_for('saml2.acs'), - isDefault => 'true', - } ] - ); - return $sp; -} - -# $returnUrl is the course URL that the user should be redirected to after successful authenticated with the IdP. -sub sendLoginRequest ($c, $returnUrl, $courseName) { - debug('Creating Login Request'); - my $idp = $c->saml2->idp; - my $sp = $c->saml2->sp; - my $authReq = $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 IdP 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 IdP relays back. - # This information is used to send the user to the right place after login. - debug('Redirecting user to the IdP'); - return $c->redirect_to($sp->sso_redirect_binding($idp, 'SAMLRequest') - ->sign($authReq->as_xml, encode_json({ course => $courseName, url => $returnUrl }))); -} - -sub loadConf ($plugin, $pluginConf, $app) { - my $confFile = $app->home->child('conf/authen_saml2.yml'); - Exception->throw("Missing conf file: $confFile") unless -e $confFile; - my $yamlConf = LoadFile($confFile); - return { %$yamlConf, %$pluginConf }; -} - -1; diff --git a/lib/Mojolicious/Plugin/Saml2/Exception.pm b/lib/Mojolicious/Plugin/Saml2/Exception.pm deleted file mode 100644 index 8f4d65eba9..0000000000 --- a/lib/Mojolicious/Plugin/Saml2/Exception.pm +++ /dev/null @@ -1,18 +0,0 @@ -################################################################################ -# 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 Mojolicious::Plugin::Saml2::Exception; -use Mojo::Base 'Mojo::Exception', -strict; -1; diff --git a/lib/Mojolicious/Plugin/Saml2/README.md b/lib/Mojolicious/Plugin/Saml2/README.md deleted file mode 100644 index 3b4e9fb64f..0000000000 --- a/lib/Mojolicious/Plugin/Saml2/README.md +++ /dev/null @@ -1,170 +0,0 @@ -# SAML2 Authentication Plugin - -This Mojolicious plugin implements SAML2 authentication for webwork2. 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 in this -directory and a regular webwork2 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*: Set this to the IdP's metadata endpoint. -- *sp.entity_id*: The ID for the webwork2 SP. This is usually the application - root URL plus the base path to the SP. -- *sp.attributes*: A list of attribute OIDs that the SP will look at and try to - match to a webwork2 username. -- *sp.certificate*, *sp.private_key*: A unique certificate and private key must - be generated for a production deployment. The example certificate and key are - only meant for development use with the - [development test instance](#development-idp-test-instance) described below. - -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 configuration. Endpoint locations, such as metadata, can be configured -under `sp.route`. - -### Certificate and private key - -You will usually need to use the certificate and private key provided by -the maintainers of your identity provider instance. - -However, if you maintain the identity provider instance you may use OpenSSL to -generate the private key and certificate with the following command: - -```bash -openssl req -newkey rsa:4096 -new -x509 -days 3652 -nodes -out saml.crt -keyout saml.pem -``` - -That command will generate the certificate file `saml.crt` and private key file -`saml.pem`. - -### localOverrides.conf - -The webwork2 authentication system will need to be configured to use the Saml2 -module in `conf/localOverrides.conf` as in the example below. This example -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` - -The admin login module also needs to be configured as in the example below. 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. If you do this, then you will also want to disable -the password login page by setting `$external_auth = 1`. - -## Development IdP test instance - -A development SAML2 IdP is provided that uses SimpleSAMLphp. Instructions for -utilized this instance follow. - -### Development IdP docker test instance - -A development use SAML2 IdP is provided in the docker-compose.yml.dist file. 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 development IdP is a -SimpleSAMLphp instance. - -Stop the docker services with - -```bash -docker compose --profile saml2dev down -``` - -### Development IdP test instance without docker - -Effective development is not done in a docker container. So it is usually more -useful to set up an identity provider without docker. - -TODO: Add these instructions. - -### Setup - -The default `conf/authen_saml2.dist.yml` is configured to use the docker -development IdP. Copy it to `conf/authen_saml2.yml` and it should work. - -TODO: Add instructions for setting up without docker. - -### IdP administration - -The development IdP 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 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. - -### 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 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 to webwork2. -Example error message: - -```text -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. - -#### The docker development 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 866ed0807e..0000000000 --- a/lib/Mojolicious/Plugin/Saml2/Router.pm +++ /dev/null @@ -1,37 +0,0 @@ -################################################################################ -# 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 Mojolicious::Plugin::Saml2::Router; -use Mojo::Base -signatures; - -use WeBWorK::Utils::Routes qw(add_route); -use WeBWorK::Utils qw(x); - -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('ACS#metadata')->name('saml2.metadata'); - $subRouter->get($conf->{sp}{route}{error})->to('ACS#errorResponse')->name('saml2.error'); - $subRouter->post($conf->{sp}{route}{acs})->to('ACS#assertionConsumerService')->name('saml2.acs'); - - # The only time the route title is used for the saml2.acs route is - # when authentication fails and the login page is rendered.. - add_route('saml2.acs', title => x('Authentication Failure')); - - return; -} - -1; diff --git a/lib/Mojolicious/WeBWorK.pm b/lib/Mojolicious/WeBWorK.pm index 3dd8de3d68..66281467f9 100644 --- a/lib/Mojolicious/WeBWorK.pm +++ b/lib/Mojolicious/WeBWorK.pm @@ -95,9 +95,6 @@ sub startup ($app) { # Provide the ability to serve data as a file download. $app->plugin('RenderFile'); - # Load the SAML2 plugin if configuration found - $app->plugin('Mojolicious::Plugin::Saml2') if -e "$ENV{WEBWORK_ROOT}/conf/authen_saml2.yml"; - # Helpers # This replaces the previous Apache2::RequestUtil method that was overridden in diff --git a/lib/WeBWorK.pm b/lib/WeBWorK.pm index 013ce7019e..c029d4fbf2 100644 --- a/lib/WeBWorK.pm +++ b/lib/WeBWorK.pm @@ -152,6 +152,9 @@ async sub dispatch ($c) { if ($routeCaptures{courseID}) { debug("We got a courseID from the route, now we can do some stuff:\n"); + # This route needs a course, but does not need authentication. + return 1 if $c->current_route eq 'saml2_course_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"); diff --git a/lib/WeBWorK/Authen/Saml2.pm b/lib/WeBWorK/Authen/Saml2.pm index e39e0fa283..87119db78a 100644 --- a/lib/WeBWorK/Authen/Saml2.pm +++ b/lib/WeBWorK/Authen/Saml2.pm @@ -16,31 +16,29 @@ package WeBWorK::Authen::Saml2; use Mojo::Base 'WeBWorK::Authen', -signatures; +use Mojo::File qw(path tempfile); +use Mojo::JSON qw(encode_json); +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}; - # Skip if the Saml2 plugin config is missing. This means the plugin isn't loaded. - if (!-e $c->app->home->child('conf/authen_saml2.yml')) { - debug('The Saml2 authen module requires the Saml2 plugin to be configured.'); - return 0; - } # Skip if the bypass_query param is set. - my $bypassQuery = $c->saml2->config->{bypass_query}; - if ($bypassQuery && $c->param($bypassQuery)) { - debug('Saml2 authen module bypass detected. Going to next module.'); + 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; } @@ -51,7 +49,7 @@ sub verify ($self) { my $result = $self->SUPER::verify; # If two factor verification is needed, defer that until after redirecting to the regular webwork2 route. - if ($self->{c}->current_route eq 'saml2.acs' + if ($self->{c}->current_route eq 'saml2_acs' && $self->{c}->stash->{saml2_redirect} && $self->session->{two_factor_verification_needed}) { @@ -64,25 +62,32 @@ sub verify ($self) { } sub do_verify ($self) { - my $c = $self->{c}; + my $c = $self->{c}; + my $ce = $c->ce; - if ($c->current_route eq 'saml2.acs') { + if ($c->current_route eq 'saml2_acs') { debug('Verifying Saml2 assertion'); - my $conf = $c->saml2->config; + my $saml2IDPCertDir = path("$ce->{webworkDirs}{DATA}/Saml2IDPCerts"); + $saml2IDPCertDir->make_path; + + my $idpCertificateFile = + tempfile(Mojo::URL->new($ce->{saml2}{idp}{metadata_url})->host . '_XXXXXXXX', DIR => $saml2IDPCertDir); + $idpCertificateFile->spew($self->idp->cert('signing')->[0]); - # Verify that the response is signed by the IdP and decode it. - my $decodedXml = Net::SAML2::Binding::POST->new(cacert => $c->saml2->idpCertificateFile->to_string) + # 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}); + say $c->dumper($decodedXml); my $assertion = Net::SAML2::Protocol::Assertion->new_from_xml( xml => $decodedXml, - key_file => $c->saml2->spKeyFile->to_string + key_file => $self->spKeyFile->to_string ); - # Get the database key containing the authReqId that was generated before redirecting to the IdP. + # 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(x('Invalid user ID or password.')); + $c->stash->{authen_error} = $c->maketext('Invalid user ID or password.'); debug('Invalid request id in response. Possible CSFR.'); return 0; } @@ -90,15 +95,15 @@ sub do_verify ($self) { # 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($conf->{sp}{entity_id}, $authReqIdKey->user_id); + my $valid = $assertion->valid($ce->{saml2}{sp}{entity_id}, $authReqIdKey->user_id); unless ($valid) { - $c->stash->{authen_error} = $c->maketext(x('Invalid user ID or password.')); + $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($conf->{sp}{attributes}, $assertion, $c->stash->{saml2}{relayState}); + my $userId = $self->getUserId($ce->{saml2}{sp}{attributes}, $assertion); if ($userId) { debug("Got username $userId"); @@ -109,13 +114,13 @@ sub do_verify ($self) { return 1; } } - $c->stash->{authen_error} = 'User not found in course.'; + $c->stash->{authen_error} = $c->maketext('User not found in course.'); debug('Unauthorized - User not found in ' . $c->stash->{courseID}); return 0; } # If there is an existing session, then control will be passed to the authen base class. - if ($c->ce->{session_management_via} eq 'session_cookie') { + if ($ce->{session_management_via} eq 'session_cookie') { my ($cookieUser) = $self->fetchCookie; $self->{isLoggedIn} = 1 if defined $cookieUser; } elsif ($c->param('user')) { @@ -137,19 +142,82 @@ sub do_verify ($self) { return $result; } - # This occurs if the user clicks the logout button when the IdP 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. + # 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 IdP for login. - debug('Redirecting to IdP for login.'); - $c->saml2->sendLoginRequest($c->req->url->to_string, $c->ce->{courseName}); + # The user doesn't have an existing session, so redirect to the identity provider for login. + $self->sendLoginRequest; - # This request has failed to verify, but this doesn't matter because the user gets redirected to the IdP. return 0; } -sub getUserId ($self, $attributeKeys, $assertion, $relayState) { +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', + } ] + ); + + return $c->stash->{sp}; +} + +sub idp ($self) { + return $self->{idp} if $self->{idp}; + $self->{idp} = Net::SAML2::IdP->new_from_url(url => $self->{c}->ce->{saml2}{idp}{metadata_url}); + return $self->{idp}; +} + +sub spKeyFile ($self) { + my $c = $self->{c}; + return $self->{spKeyFile} if $self->{spKeyFile}; + $self->{spKeyFile} = path($c->ce->{saml2}{sp}{public_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 $authReq = $self->sp->authn_request($self->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'); + return $c->redirect_to($self->sp->sso_redirect_binding($self->idp, 'SAMLRequest') + ->sign($authReq->as_xml, encode_json({ course => $c->ce->{courseName}, url => $c->req->url->to_string }))); +} + +sub getUserId ($self, $attributeKeys, $assertion) { my $ce = $self->{c}->ce; my $db = $self->{c}->db; @@ -175,7 +243,7 @@ sub getUserId ($self, $attributeKeys, $assertion, $relayState) { sub get_credentials ($self) { if ($self->{saml2UserId}) { - # User has been authenticated with the IdP. + # User has been authenticated with the identity provider. $self->{user_id} = $self->{saml2UserId}; $self->{login_type} = 'normal'; $self->{credential_source} = 'SAML2'; @@ -188,7 +256,7 @@ sub get_credentials ($self) { } sub authenticate ($self) { - # The IdP handles authentication, so just return 1. + # The identity provider handles authentication, so just return 1. return 1; } diff --git a/lib/Mojolicious/Plugin/Saml2/Controller/ACS.pm b/lib/WeBWorK/ContentGenerator/Saml2.pm similarity index 85% rename from lib/Mojolicious/Plugin/Saml2/Controller/ACS.pm rename to lib/WeBWorK/ContentGenerator/Saml2.pm index 4c44df4480..09077c779b 100644 --- a/lib/Mojolicious/Plugin/Saml2/Controller/ACS.pm +++ b/lib/WeBWorK/ContentGenerator/Saml2.pm @@ -13,7 +13,7 @@ # Artistic License for more details. ################################################################################ -package Mojolicious::Plugin::Saml2::Controller::ACS; +package WeBWorK::ContentGenerator::Saml2; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; use Mojo::JSON qw(decode_json); @@ -21,7 +21,7 @@ use Mojo::JSON qw(decode_json); use WeBWorK::Debug qw(debug); sub initializeRoute ($c, $routeCaptures) { - if ($c->current_route eq 'saml2.acs') { + 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'); @@ -36,7 +36,8 @@ sub assertionConsumerService ($c) { } sub metadata ($c) { - return $c->render(data => $c->saml2->sp->metadata, format => 'xml'); + 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) { diff --git a/lib/WeBWorK/Utils/Routes.pm b/lib/WeBWorK/Utils/Routes.pm index 95128f9fd3..cb49f658e2 100644 --- a/lib/WeBWorK/Utils/Routes.pm +++ b/lib/WeBWorK/Utils/Routes.pm @@ -39,6 +39,10 @@ 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 + pod_index /pod pod_viewer /pod/$filePath @@ -54,6 +58,7 @@ PLEASE FOR THE LOVE OF GOD UPDATE THIS IF YOU CHANGE THE ROUTES BELOW!!! equation_display /$courseID/equation feedback /$courseID/feedback gateway_quiz /$courseID/test_mode/$setID + saml2_course_metadata /$courseID/saml2/metadata proctored_gateway_quiz /$courseID/proctored_test_mode/$setID proctored_gateway_proctor_login /$courseID/proctored_test_mode/$setID/proctor_login @@ -155,6 +160,9 @@ my %routeParameters = ( ltiadvantage_launch ltiadvantage_keys ltiadvantage_content_selection + saml2_acs + saml2_metadata + saml2_error pod_index sample_problem_index set_list @@ -222,6 +230,26 @@ 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' + }, + pod_index => { title => x('POD Index'), children => [qw(pod_viewer)], @@ -256,7 +284,7 @@ my %routeParameters = ( title => '[_4]', children => [ qw(equation_display feedback gateway_quiz proctored_gateway_quiz answer_log grades hardcopy achievements - logout options instructor_tools problem_list) + logout options instructor_tools problem_list saml2_course_metadata) ], module => 'ProblemSets', path => '/#courseID' @@ -301,6 +329,12 @@ my %routeParameters = ( path => '/test_mode/#setID', unrestricted => 1 }, + saml2_course_metadata => { + title => 'metadata', + module => 'Saml2', + path => '/saml2/metadata', + action => 'metadata' + }, proctored_gateway_quiz => { title => x('Proctored Test [_2]'), @@ -534,7 +568,7 @@ my %routeParameters = ( module => 'ShowMeAnother', path => '/show_me_another', unrestricted => 1 - } + }, ); =head1 METHODS @@ -625,6 +659,8 @@ Returns 1 if the route is restricted from being viewed by a user that does not have the navigation_allowed permission, and 0 otherwise. The allowed paths for restricted users are marked with the unrestricted flag. +=back + =cut sub route_navigation_is_restricted { @@ -632,23 +668,4 @@ sub route_navigation_is_restricted { return defined $routeParameters{ $route->name }{unrestricted} ? 0 : 1; } -=item add_route - -Add a route to the internal C<$routeParameters> hash. This is intended to be -used by plugins that add routes. Note that since the added routes will not be -children of any route, the caller is responsible for actually adding the routes -to the router. They will not be added when the -C method is called. Really, only routes for -which a route title is needed need to be added via this method at this point. - -=back - -=cut - -sub add_route { - my ($route_name, %route_options) = @_; - $routeParameters{$route_name} = {%route_options}; - return; -} - 1;