Skip to content

Commit

Permalink
Fix Seneca-CDOT#709: add backend support for Admin users
Browse files Browse the repository at this point in the history
  • Loading branch information
humphd committed Mar 30, 2020
1 parent 0e60a79 commit b40cba2
Show file tree
Hide file tree
Showing 9 changed files with 171 additions and 36 deletions.
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions docs/login.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions env.staging
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions saml20-idp-hosted.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php
/*
Specify that we want the user's nameID to be persistent, and use the email:
https://stackoverflow.com/questions/50260272/how-to-replace-a-value-of-nameid-with-attribute-in-simplesamlphp-based-idp
*/

$metadata['__DYNAMIC:1__'] = array(
/*
* The hostname of the server (VHOST) that will use this SAML entity.
*
* Can be '__DEFAULT__', to use this entry by default.
*/
'host' => '__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'
)
)

);
13 changes: 11 additions & 2 deletions simplesamlphp-users.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
),
),
Expand Down
1 change: 1 addition & 0 deletions src/backend/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
139 changes: 109 additions & 30 deletions src/backend/web/authentication.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,59 +113,138 @@ 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
res.status(403).send('Forbidden');
}
}

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;
5 changes: 3 additions & 2 deletions src/backend/web/routes/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ 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();

// 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');
Expand Down

0 comments on commit b40cba2

Please sign in to comment.