From e8cee1f5778e5a4c18eba55732a790e6ba21bc6d Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Mon, 19 Aug 2024 20:09:41 -0500 Subject: [PATCH] Lots more to document here. Fix things for `$session_management_via = "key"`. Fix using Saml2 in conjunction with two factor authentication. --- conf/authen_saml2.dist.yml | 157 ++++++--------- docker-config/docker-compose.dist.yml | 31 +-- docker-config/idp/Dockerfile | 26 +-- docker-config/idp/apache.conf | 38 ---- docker-config/idp/certs/saml.crt | 29 +++ docker-config/idp/certs/saml.pem | 40 ++++ docker-config/idp/config/authsources.php | 1 - docker-config/idp/config/config.php | 4 +- docker-config/idp/config/module_cron.php | 4 +- .../idp/config/module_metarefresh.php | 12 +- docker-config/idp/idp.apache2.conf | 7 + lib/Mojolicious/Plugin/Saml2.pm | 123 ++++++++++++ .../Plugin/Saml2/Controller/ACS.pm | 31 +++ .../Saml2/Controller/AcsPostController.pm | 101 ---------- .../Saml2/Controller/ErrorController.pm | 9 - .../Saml2/Controller/MetadataController.pm | 10 - lib/Mojolicious/Plugin/Saml2/Exception.pm | 2 +- lib/Mojolicious/Plugin/Saml2/README.md | 155 +++++++-------- lib/Mojolicious/Plugin/Saml2/Router.pm | 18 +- lib/Mojolicious/Plugin/Saml2/Saml2Plugin.pm | 155 --------------- lib/Mojolicious/WeBWorK.pm | 5 +- lib/WeBWorK.pm | 19 +- lib/WeBWorK/Authen/Saml2.pm | 179 +++++++++++++----- lib/WeBWorK/Utils/Routes.pm | 25 ++- .../Base/login_status.html.ep | 2 +- templates/ContentGenerator/Login.html.ep | 9 +- templates/ContentGenerator/Logout.html.ep | 5 +- 27 files changed, 585 insertions(+), 612 deletions(-) delete mode 100644 docker-config/idp/apache.conf create mode 100644 docker-config/idp/certs/saml.crt create mode 100644 docker-config/idp/certs/saml.pem create mode 100644 docker-config/idp/idp.apache2.conf create mode 100644 lib/Mojolicious/Plugin/Saml2.pm create mode 100644 lib/Mojolicious/Plugin/Saml2/Controller/ACS.pm delete mode 100644 lib/Mojolicious/Plugin/Saml2/Controller/AcsPostController.pm delete mode 100644 lib/Mojolicious/Plugin/Saml2/Controller/ErrorController.pm delete mode 100644 lib/Mojolicious/Plugin/Saml2/Controller/MetadataController.pm delete mode 100644 lib/Mojolicious/Plugin/Saml2/Saml2Plugin.pm diff --git a/conf/authen_saml2.dist.yml b/conf/authen_saml2.dist.yml index 6200516132..9793f764f2 100644 --- a/conf/authen_saml2.dist.yml +++ b/conf/authen_saml2.dist.yml @@ -4,117 +4,74 @@ # # 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 +# 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 +# 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 -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) + +# 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 - # endoints created by the plugin, relative to the webwork root url + + # Endpoints used by the plugin. They are all 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' + # 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 - # I've filled out the org info with app info instead. + # 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 + 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 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' + - 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. + # 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. ############################################################################## - # 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----- + # 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/docker-config/docker-compose.dist.yml b/docker-config/docker-compose.dist.yml index a168a9f96e..8aee898c0e 100644 --- a/docker-config/docker-compose.dist.yml +++ b/docker-config/docker-compose.dist.yml @@ -251,7 +251,7 @@ services: #ports: # - "6311:6311" - # saml2 dev use only, separate profile from the other services so it doesn't + # Saml2 dev 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. idp: build: @@ -261,37 +261,16 @@ services: 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/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. 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/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..7abc4c87ff 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 @@ -281,7 +281,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..cb1fe131b6 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.pm b/lib/Mojolicious/Plugin/Saml2.pm new file mode 100644 index 0000000000..2ce8eaff68 --- /dev/null +++ b/lib/Mojolicious/Plugin/Saml2.pm @@ -0,0 +1,123 @@ +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/Controller/ACS.pm b/lib/Mojolicious/Plugin/Saml2/Controller/ACS.pm new file mode 100644 index 0000000000..3991eba022 --- /dev/null +++ b/lib/Mojolicious/Plugin/Saml2/Controller/ACS.pm @@ -0,0 +1,31 @@ +package Mojolicious::Plugin::Saml2::Controller::ACS; +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}; + } + 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 => $c->saml2->sp->metadata, format => 'xml'); +} + +sub errorResponse ($c) { + return $c->reply->exception('SAML2 Login Error')->rendered(400); +} + +1; 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 index 43d5cd88e0..d7aeb746e3 100644 --- a/lib/Mojolicious/Plugin/Saml2/Exception.pm +++ b/lib/Mojolicious/Plugin/Saml2/Exception.pm @@ -1,3 +1,3 @@ package Mojolicious::Plugin::Saml2::Exception; -use Mojo::Base 'Mojo::Exception', -signatures; +use Mojo::Base 'Mojo::Exception', -strict; 1; diff --git a/lib/Mojolicious/Plugin/Saml2/README.md b/lib/Mojolicious/Plugin/Saml2/README.md index f531c80ea9..3b4e9fb64f 100644 --- a/lib/Mojolicious/Plugin/Saml2/README.md +++ b/lib/Mojolicious/Plugin/Saml2/README.md @@ -1,14 +1,14 @@ # SAML2 Authentication Plugin -This Mojolicious plugin implements SAML2 authentication for Webwork. SAML2 +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 +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`. +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 @@ -17,146 +17,153 @@ To enable the Saml2 plugin, copy `conf/authen_saml2.dist.yml` to 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). +- *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 config. Endpoint locations, such as metadata, can be configured under -`sp.route`. +default configuration. Endpoint locations, such as metadata, can be configured +under `sp.route`. -### Generate key and cert +### Certificate and private key -OpenSSL can be used to generate the key and cert, like the following command: +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 ``` -The cert is placed in `saml.crt`. The key is in `saml.pem`. +That command will generate the certificate file `saml.crt` and private key file +`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: +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::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.: +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: +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' + 'WeBWorK::Authen::Saml2' ]; ``` To disable the bypass, `conf/authen_saml2.yml` must also be edited, commenting -out the `bypass_query` line. +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. -## Docker Compose Dev +### Development IdP docker test instance -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: +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 dev IdP is a +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 this dev IdP. -Just copy it to `conf/authen_saml2.yml` and it should work. +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. -### Admin +TODO: Add instructions for setting up without docker. -The dev IdP has an admin interface, you can login with the password 'admin' at: +### IdP administration -```text -http://localhost:8180/simplesaml/module.php/admin/federation -``` +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 +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 +### Single sign-on users -There are some single sign-on accounts preconfigured: +The following single sign-on accounts are preconfigured: -- Username: student01 - - Password: student01 -- Username: instructor01 - - Password: instructor01 -- Username: staff01 - - Password: staff01 +- 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. +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 +### Troubleshooting -### Webwork doesn't start, "Error retrieving metadata" +#### 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. +from the IdP metadata url. Make sure the IdP is accessible to webwork2. Example error message: ```text -app-1 | Can't load application from file "/opt/webwork/webwork2/bin/webwork2": +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 +#### 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 +#### 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 diff --git a/lib/Mojolicious/Plugin/Saml2/Router.pm b/lib/Mojolicious/Plugin/Saml2/Router.pm index ac4dd5cb7b..03160f4a94 100644 --- a/lib/Mojolicious/Plugin/Saml2/Router.pm +++ b/lib/Mojolicious/Plugin/Saml2/Router.pm @@ -1,17 +1,21 @@ 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(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'); + $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. + add_route('saml2.acs', title => x('Authentication Failure')); + + return; } 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..3dd8de3d68 100644 --- a/lib/Mojolicious/WeBWorK.pm +++ b/lib/Mojolicious/WeBWorK.pm @@ -94,10 +94,9 @@ 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'); - } + $app->plugin('Mojolicious::Plugin::Saml2') if -e "$ENV{WEBWORK_ROOT}/conf/authen_saml2.yml"; # Helpers diff --git a/lib/WeBWorK.pm b/lib/WeBWorK.pm index 0ac5eeba66..013ce7019e 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"); @@ -169,7 +157,6 @@ async sub dispatch ($c) { || -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..af871c479e 100644 --- a/lib/WeBWorK/Authen/Saml2.pm +++ b/lib/WeBWorK/Authen/Saml2.pm @@ -16,6 +16,9 @@ package WeBWorK::Authen::Saml2; use Mojo::Base 'WeBWorK::Authen', -signatures; +use Net::SAML2::Binding::POST; +use Net::SAML2::Protocol::Assertion; + use WeBWorK::Debug qw(debug); =head1 NAME @@ -28,75 +31,165 @@ Requires the Saml2 plugin to be loaded and configured. 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 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 we have the param that indicates we want to bypass SAML2 - my $bypassQuery = $c->saml2->getConf->{bypass_query}; + # 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'); + debug('Saml2 authen module bypass detected. Going to next module.'); 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); - } return 1; } +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' + && $self->{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) { - 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(); + my $c = $self->{c}; + + if ($c->current_route eq 'saml2.acs') { + debug('Verifying Saml2 assertion'); + + my $conf = $c->saml2->config; + + # 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) + ->handle_response($c->stash->{saml2}{samlResp}); + my $assertion = Net::SAML2::Protocol::Assertion->new_from_xml( + xml => $decodedXml, + key_file => $c->saml2->spKeyFile + ); + + # Get the database key containing the authReqId that was generated before redirecting to the IdP. + my $authReqIdKey = $c->db->getKey($assertion->in_response_to); + unless ($authReqIdKey) { + $c->stash->{authen_error} = $c->maketext(x('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($conf->{sp}{entity_id}, $authReqIdKey->user_id); + unless ($valid) { + $c->stash->{authen_error} = $c->maketext(x('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}); + if ($userId) { + debug("Got username $userId"); + + $c->authen->{saml2UserId} = $userId; + if ($self->SUPER::do_verify) { + $c->param('user', $userId); + $c->stash->{saml2_redirect} = $c->systemLink($c->url_for($c->stash->{saml2}{relayState}{url})); + return 1; + } + } + $c->stash->{authen_error} = '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') { + 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; + } + + 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; } - # user doesn't have an existing session, send them to IdP for login - 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 + + # 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. + 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}); + + # 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) { + 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 IdP. $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 IdP 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/Utils/Routes.pm b/lib/WeBWorK/Utils/Routes.pm index 3197229372..95128f9fd3 100644 --- a/lib/WeBWorK/Utils/Routes.pm +++ b/lib/WeBWorK/Utils/Routes.pm @@ -122,7 +122,7 @@ use WeBWorK::Localize; use WeBWorK::Utils qw(x); use WeBWorK::Utils::Sets qw(format_set_name_display); -our @EXPORT_OK = qw(setup_content_generator_routes route_title route_navigation_is_restricted); +our @EXPORT_OK = qw(setup_content_generator_routes route_title route_navigation_is_restricted add_route); # Tree of route parameters. # Parameters: @@ -606,7 +606,7 @@ sub route_title { # Translate the display name. my $name = $c->maketext( - $routeParameters{$route_name}{title}, + $routeParameters{$route_name}{title} // 'unknown', $c->stash('userID') // '', $displayHTML ? $c->tag('span', dir => 'ltr', format_set_name_display($c->stash('setID') // '')) @@ -625,8 +625,6 @@ 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 { @@ -634,4 +632,23 @@ 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; diff --git a/templates/ContentGenerator/Base/login_status.html.ep b/templates/ContentGenerator/Base/login_status.html.ep index cbbf851f15..799cbdca12 100644 --- a/templates/ContentGenerator/Base/login_status.html.ep +++ b/templates/ContentGenerator/Base/login_status.html.ep @@ -3,7 +3,7 @@ % % my $userID = param('user'); % my $user = $db->getUser($userID); - % my $effectiveUserID = param('effectiveUser'); + % my $effectiveUserID = param('effectiveUser') // ''; % my $userName = $user->full_name || $user->user_id; % <%= maketext('Logged in as [_1].', $userName) %> 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 =%>