diff --git a/docker-compose.yml b/docker-compose.yml index e4ed196800..6b465393c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,7 @@ services: - SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE=${SLO_LOGOUT_CALLBACK_URL} volumes: - ./simplesamlphp-users.php:/var/www/simplesamlphp/config/authsources.php + - ./saml20-idp-hosted.php:/var/www/simplesamlphp/metadata/saml20-idp-hosted.php # image owner's blog post https://medium.com/disney-streaming/setup-a-single-sign-on-saml-test-environment-with-docker-and-nodejs-c53fc1a984c9 image: kristophjunge/test-saml-idp ports: diff --git a/docs/login.md b/docs/login.md index e2a75a9b01..e26c27f1f9 100644 --- a/docs/login.md +++ b/docs/login.md @@ -37,8 +37,8 @@ router.use('/secure/route', passport.authenticate('saml'), (req, res) => { }); ``` -If you need to protect an HTTP REST API route, use the `protect` middleware -from `src/backend/web/authentication.js`. +If you need to protect an HTTP REST API route, use the `protect` (regular +users) or `protectAdmin` (admin users) middleware from `src/backend/web/authentication.js`. ## Running an SSO Identity Provider diff --git a/env.example b/env.example index c396fed59e..1a7eef8766 100644 --- a/env.example +++ b/env.example @@ -65,6 +65,11 @@ SAML_ENTITY_ID=http://localhost:3000/sp # SECRET = cookie session SECRET. If left empty, one will be set automatically SECRET= +# ADMINISTRATORS is a list (space delimited) of users who have administrator +# rights. Use the user's nameID (user2@example.com) or hashed version of +# nameID ("2b3b2b9ce8"). Either will work. +ADMINISTRATORS="user1@example.com" + # BLOG_INACTIVE_TIME is the period (days) of inactivity # before a blog will be considered redlisted BLOG_INACTIVE_TIME=360 diff --git a/env.staging b/env.staging index c2e68bafef..877d498e7d 100644 --- a/env.staging +++ b/env.staging @@ -55,6 +55,11 @@ SAML_ENTITY_ID=https://dev.telescope.cdot.systems/sp # SECRET = cookie session SECRET. If left empty, one will be set automatically SECRET= +# ADMINISTRATORS is a list (space delimited) of users who have administrator +# rights. Use the user's nameID (user2@example.com) or hashed version of +# nameID ("2b3b2b9ce8"). Either will work. +ADMINISTRATORS="david.humphrey@sencollege.ca jquilon-barrios@sencollege.ca" + # BLOG_INACTIVE_TIME is the period (days) of inactivity # before a blog will be considered redlisted BLOG_INACTIVE_TIME=360 diff --git a/saml20-idp-hosted.php b/saml20-idp-hosted.php new file mode 100644 index 0000000000..1ecd965e72 --- /dev/null +++ b/saml20-idp-hosted.php @@ -0,0 +1,34 @@ + '__DEFAULT__', + // X.509 key and certificate. Relative to the cert directory. + 'privatekey' => 'server.pem', + 'certificate' => 'server.crt', + /* + * Authentication source to use. Must be one that is configured in + * 'config/authsources.php'. + */ + 'auth' => 'example-userpass', + + 'NameIDFormat' => 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', + + // refer to https://simplesamlphp.org/docs/stable/saml:nameid + 'authproc' => array( + 3 => array( + 'class' => 'saml:AttributeNameID', + 'attribute' => 'email', + 'Format' => 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' + ) + ) + +); diff --git a/simplesamlphp-users.php b/simplesamlphp-users.php index 7f693009bb..2eb6d17bde 100644 --- a/simplesamlphp-users.php +++ b/simplesamlphp-users.php @@ -15,19 +15,28 @@ 'user1:user1pass' => array( 'uid' => array('1'), 'eduPersonAffiliation' => array('group1'), + /* + NOTE: we need both `email` and `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` + for each user. The `email` will be used for nameID, the `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` + will be added to the profile to match what we get back from Seneca's IdP. Make sure these + match for both fields on every user. + */ 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' => 'user1@example.com', + 'email' => 'user1@example.com', 'http://schemas.microsoft.com/identity/claims/displayname' => 'Johannes Kepler' ), 'user2:user2pass' => array( 'uid' => array('2'), 'eduPersonAffiliation' => array('group2'), 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' => 'user2@example.com', + 'email' => 'user2@example.com', 'http://schemas.microsoft.com/identity/claims/displayname' => 'Galileo Galilei', ), - 'LippersheyH:telescope' => array( + 'lippersheyh:telescope' => array( 'uid' => array('2'), 'eduPersonAffiliation' => array('group2'), - 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' => 'HansLippershey@example.com', + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' => 'hans-lippershey@example.com', + 'email' => 'hans-lippershey@example.com', 'http://schemas.microsoft.com/identity/claims/displayname' => 'Hans Lippershey', ), ), diff --git a/src/backend/web/app.js b/src/backend/web/app.js index 8a2fd7728c..af39133028 100644 --- a/src/backend/web/app.js +++ b/src/backend/web/app.js @@ -39,6 +39,7 @@ app.use( authentication.init(); app.use(passport.initialize()); app.use(passport.session()); +app.use(authentication.administration()); // Add the Apollo server to app and define the `/graphql` endpoint const server = new ApolloServer({ diff --git a/src/backend/web/authentication.js b/src/backend/web/authentication.js index fe975c8112..826d340ef5 100644 --- a/src/backend/web/authentication.js +++ b/src/backend/web/authentication.js @@ -113,36 +113,20 @@ function samlMetadata() { return strategy.generateServiceProviderMetadata(); } -/** - * Middleware to make sure that a route is authenticated. If the user is - * already authenticated, your route will be called. If the user is already - * authenticated, the next() route will be be called, otherwise an HTTP 403 - * will be returned on the response. This is probably what you want for a - * REST API endpoint. - * - * If you want to give the user a chance to log in (e.g., web page vs. REST - * API endpoint), you can pass `true` to protect - * - * To use: - * - * router.get('/rest/api', protect(), function(res, res) { ... })) - * - * router.get('/protected/html/page', protect(true), function(res, res) { ... })) - */ +// If user is not authenticated, remember where they were trying to go +// and let passport do full authentication and give chance to log in. function protectWithRedirect(req, res, next) { - // If user is not authenticated, remember where they were trying to go - // and let passport do full authentication and give chance to log in. if (req.session) { req.session.returnTo = req.originalUrl; } passport.authenticate('saml')(req, res, next); } -function protectWithoutRedirect(req, res) { - // If user is not authenticated, return an appropriate 400 error type +// If user is not authenticated, return an appropriate 400 error type +function forbidden(req, res) { if (req.accepts('json')) { res.status(403).json({ - message: 'Forbidden: you need to login first.', + message: 'Forbidden', }); } else { // TODO: https://github.com/Seneca-CDOT/telescope/issues/890 @@ -150,22 +134,117 @@ function protectWithoutRedirect(req, res) { } } -function protect(redirect) { - return function(req, res, next) { - // If the user is already authenticated, let this pass to next route - if (req.isAuthenticated()) { +// If we aren't redirecting, we're going to forbid this request +function protectWithoutRedirect(req, res) { + forbidden(req, res); +} + +/** + * Check whether a user is authenticated. If `requireAdmin` is `true`, + * we also require that this user be an administrator. If `redirect` is + * `true`, we will send the request to the login page if not authenticated, + * otherwise we fail it with a 403. + */ +function checkUser(requireAdmin, redirect, req, res, next) { + // First, see if the user is already authenticated + if (req.isAuthenticated()) { + // Next, check to see if we need admin rights to pass + if (requireAdmin) { + // See if this user is an admin + if (req.user.isAdmin) { + next(); + } + // Not an admin, so fail this now using best response type + else { + forbidden(req, res); + } + } else { + // We don't need an admin, and this is a regular authenticated user, let it pass next(); } - // If not authenticated, pick the right way to handle this - else if (redirect) { - protectWithRedirect(req, res, next); - } else { - protectWithoutRedirect(req, res, next); + } + // If not authenticated, pick the right way to handle this with respect to redirects + else if (redirect) { + protectWithRedirect(req, res, next); + } else { + protectWithoutRedirect(req, res, next); + } +} + +/** + * We define an administrator as someone who is specified in the .env + * ADMINISTRATORS variable list. We support bare email addresses and hashed. + * See env.sample for more details. + */ +function getAdminList(administrators) { + return administrators ? administrators.split(' ') : []; +} +const admins = getAdminList(process.env.ADMINISTRATORS); + +// See if this user id is in the admins env as a raw or hashed value +function userIsAdmin(id) { + return admins.some(admin => id === admin || id === hash(admin)); +} + +/** + * Middleware to determine if a user on the session is an administrator or not. + * In both cases, we add an `.isAdmin` property, and set it to `true` only if + * the current user's id (i.e. ,nameID in SAML) matches what we have set in the + * env for ADMINISTRATORS. There can be more than one admin user. After this + * middleware updates the `user`, you can use `req.user.isAdmin` to check whether + * or not a user is an administrator. + */ +function administration() { + return function(req, res, next) { + if (req.user && req.user.id) { + req.user.isAdmin = userIsAdmin(req.user.id); } + next(); + }; +} + +/** + * Middleware to make sure that a route is authenticated. If the user is + * already authenticated, your route will be called. If the user is already + * authenticated, the next() route will be be called, otherwise an HTTP 403 + * will be returned on the response. This is probably what you want for a + * REST API endpoint. + * + * If you want to give the user a chance to log in (e.g., web page vs. REST + * API endpoint), you can pass `true` to protect + * + * To use: + * + * router.get('/rest/api', protect(), function(res, res) { ... })) + * + * router.get('/protected/html/page', protect(true), function(res, res) { ... })) + */ +function protect(redirect) { + return function(req, res, next) { + checkUser(false, redirect, req, res, next); + }; +} + +/** + * Middleware to make sure that a route is authenticated AND that the + * user is a member of the admins we define in our env. See protect() above + * for more details on how this middle should be used in general. + * + * To use: + * + * router.get('/rest/api/for/admins', protectAdmin(), function(res, res) { ... })) + * + * router.get('/protected/html/page/for/admins', protectAdmin(true), function(res, res) { ... })) + */ +function protectAdmin(redirect) { + return function(req, res, next) { + checkUser(true, redirect, req, res, next); }; } module.exports.init = init; module.exports.protect = protect; +module.exports.protectAdmin = protectAdmin; +module.exports.administration = administration; module.exports.strategy = strategy; module.exports.samlMetadata = samlMetadata; diff --git a/src/backend/web/routes/admin.js b/src/backend/web/routes/admin.js index 8cd1b5d10f..ac2eb3fed8 100644 --- a/src/backend/web/routes/admin.js +++ b/src/backend/web/routes/admin.js @@ -3,7 +3,7 @@ const express = require('express'); const { UI } = require('bull-board'); const fs = require('fs'); -const { protect } = require('../authentication'); +const { protect, protectAdmin } = require('../authentication'); const { logger } = require('../../utils/logger'); const router = express.Router(); @@ -11,7 +11,8 @@ const router = express.Router(); // Only authenticated users can use these routes router.use('/queues', protect(true), UI); -router.get('/log', protect(true), (req, res) => { +// Only authenticated admin users can see this route +router.get('/log', protectAdmin(true), (req, res) => { let readStream; if (!process.env.LOG_FILE) { res.send('LOG_FILE undefined in .env file');