Commit cb42222a authored by Nelson Gonçalves's avatar Nelson Gonçalves
Browse files

Add SSO Auth with laclasse

parent aaf57f80
......@@ -11,5 +11,6 @@ COPY ./docker-entrypoint.sh /docker-entrypoint.sh
COPY ./nginx/default.conf.tmpl /etc/nginx/conf.d/default.conf.tmpl
COPY ./pad.js /app/src/static/custom/pad.js
COPY ./ep_laclasse/ /app/node_modules/ep_laclasse/
COPY ./ep_disable_change_author_name/ /app/node_modules/ep_disable_change_author_name/
EXPOSE 80
CMD [ "/docker-entrypoint.sh" ]
......@@ -12,8 +12,11 @@ fi
if [ -z "$AUTH_URL" ]; then
export AUTH_URL="http://service/api/docs/current/isauthenticated"
fi
if [ -z "$SSO_URL" ]; then
export SSO_URL="$BASE_URL"
fi
REPLACE_VARS='DB_HOST DB_USER DB_NAME DB_PASSWORD APIKEY AUTH_URL'
REPLACE_VARS='DB_HOST DB_USER DB_NAME DB_PASSWORD APIKEY AUTH_URL SSO_URL BASE_URL'
# check if needed vars are present
if [ -z "$DB_PASSWORD" ] || [ -z "$APIKEY" ] ; then
......@@ -23,12 +26,15 @@ if [ -z "$DB_PASSWORD" ] || [ -z "$APIKEY" ] ; then
echo " - DB_NAME: database name (default: etherpad)"
echo " - DB_PASSWORD: database password"
echo " - APIKEY: Etherpad secret API key"
echo " - BASE_URL: HTTP URL"
echo " - AUTH_URL: auth service URL (default: http://service/api/docs/current/isauthenticated)"
echo " - SSO_URL: sso service URL (default: BASE_URL)"
exit 1
fi
# generate setup files
envsubst "$(printf '${%s} ' $REPLACE_VARS)" < /app/settings.json.tmpl > /app/settings.json
envsubst "$(printf '${%s} ' $REPLACE_VARS)" < /app/node_modules/ep_laclasse/config.json.tmpl > /app/node_modules/ep_laclasse/config.json
envsubst "$(printf '${%s} ' $REPLACE_VARS)" < /etc/nginx/conf.d/default.conf.tmpl > /etc/nginx/conf.d/default.conf
echo $APIKEY > /app/APIKEY.txt
......
/* jslint node:true */
'use strict';
const request = require('request');
const cheerio = require("cheerio");
const authorManager = require('ep_etherpad-lite/node/db/AuthorManager');
const log4js = require('log4js');
const fs = require("fs");
const jsonminify = require("jsonminify");
let ssoURL = '';
let baseURL = '';
const configPath = `${__dirname}/config.json`;
try {
let configStr = fs.readFileSync(configPath, "utf8");
configStr = jsonminify(configStr).replace(",]","]").replace(",}","}");
const config = JSON.parse(configStr);
if(config.baseURL)
baseURL = config.baseURL;
else
throw "baseURL not found";
if(config.ssoURL)
ssoURL = config.ssoURL;
else
throw "ssoURL not found";
laclasseLogger.info(`Config file read from: "${configPath}"`);
} catch(e) {
laclasseLogger.error(`Config file not found or not valid:`,e.message);
}
laclasseLogger = log4js.getLogger("ep_laclasse");
exports.authorize = function (hook_name, context, cb) {
// NOTE: statics in /^\/(static|javascripts|pluginfw|api)/ are always authorized. See webaccess.js
laclasseLogger.info('authorize: for', context.resource);
// allow healthcheck without authentication
if (context.resource.match(/^\/(healthcheck)/)) return cb([true]);
// everything else at least needs a login session
if (!context.req.session.user) return cb([false]);
// protect the admin routes
if (context.resource.indexOf('/admin') === 0 && !context.req.session.user.is_admin) return cb([false]);
laclasseLogger.info('authorize: authorized');
cb([true]);
};
function getServiceURL(request) {
const protocol = request.header('x-forwarded-proto');
const host = request.header('x-forwarded-host');
return `${protocol}://${host}/pads${request.path}`;
}
function serviceValidate(ticket,service) {
return new Promise(function(resolve, reject) {
const validateURL = new URL('sso/serviceValidate',ssoURL);
const urlParams = new URLSearchParams({
ticket: ticket, service: service
});
const url = `${validateURL.toString()}?${urlParams.toString()}`;
laclasseLogger.info('ep_laclasse.serviceValidate: ', url);
request(url, function (er, response, body) {
if (er) return reject(er);
try {
return resolve(body);
} catch (err) {
return reject(err);
}
});
})
}
function getUserFromTicket(ticket) {
const ticketXML = cheerio.load(ticket, { xmlMode: true });
if(ticketXML('cas\\:authenticationFailure').length > 0)
return false;
if(ticketXML('cas\\:authenticationSuccess').length == 0)
return false;
return {
uid: ticketXML('cas\\:uid').text(),
lastname: ticketXML('cas\\:LaclasseNom').text(),
firstname: ticketXML('cas\\:LaclassePrenom').text(),
}
}
var ticket = null;
exports.authenticate = async function (hook_name, context, cb) {
laclasseLogger.info('authenticate');
if(!context.req.query.ticket) {
return cb([false]);
}
ticket = context.req.query.ticket;
const user = await serviceValidate(context.req.query.ticket, getServiceURL(context.req)).then(ticket => {
const user = getUserFromTicket(ticket);
if(!user) {
return false;
}
return user;
}).catch(err => {
laclasseLogger.error('authenticate: Ticket couldn\'t be validated', err);
return false;
});
if(user === false) {
return cb([false]);
}
//TODO Is this needed ?
// // clear any previous invalid credentials
// context.req.session.invalidCredentials = false;
// User authenticated, save off some information needed for authorization
context.req.session.user = {
username: user.uid,
display_name: `${user.firstname} ${user.lastname}`,
is_admin: true
};
//TODO Once logged in, remove ticket
// laclasseLogger.info('authenticate: successful authentication', context.req.session.user);
return cb([true]);
}
exports.authFailure = function(hook_name, context, cb) {
const baseUrl = new URL('sso/login',baseURL);
const urlParams = new URLSearchParams({ service: getServiceURL(context.req) });
const url = `${baseUrl.toString()}?${urlParams.toString()}`;
//TODO Try to redirect a few times before giving up and showing an error message
if(ticket == null)
context.res.redirect(url);
laclasseLogger.warn('authFailure: Redirect to SSO: ',url);
// signal that we have handled it
cb([true]);
}
exports.handleMessage = function (hook_name, context, cb) {
// skip if we don't have any information to set
var session = context.client.client.request.session;
if (!session || !session.user || !session.user.display_name) return cb();
authorManager.getAuthor4Token(context.message.token).then(function (author) {
authorManager.setAuthorName(author, context.client.client.request.session.user.display_name);
cb();
}).catch(function (error) {
console.error('handleMessage: could not get authorid for token %s', context.message.token, error);
cb();
});
};
\ No newline at end of file
{
// The URL used to contact the SSO
"ssoURL": "${SSO_URL}",
// The public URL to be redirected to
"baseURL": "${BASE_URL}"
}
......@@ -4,7 +4,11 @@
"name": "ep_laclasse_plugin",
"hooks":{
"userLeave": "ep_laclasse/userLeave.js",
"expressCreateServer": "ep_laclasse/httpApi.js"
"expressCreateServer": "ep_laclasse/httpApi.js",
"handleMessage": "ep_laclasse/authenticate.js",
"authenticate": "ep_laclasse/authenticate.js",
"authorize": "ep_laclasse/authenticate.js",
"authFailure": "ep_laclasse/authenticate.js"
}
}
]
......
......@@ -15,7 +15,7 @@
"peerDependencies": {
"ep_etherpad_lite":">=1.8.4",
"express-rate-limit": "5.1.X",
"http-errors": "1.7.X",
"jsonminify": "0.4.X",
"log4js": "0.6.X"
},
"license": "MIT"
......
......@@ -6,6 +6,19 @@
// alternatively, set up a fully specified Url to your own favicon
"favicon": "favicon.ico",
/*
* Skin name.
*
* Its value has to be an existing directory under src/static/skins.
* You can write your own, or use one of the included ones:
*
* - "no-skin": an empty skin (default). This yields the unmodified,
* traditional Etherpad theme.
* - "colibris": the new experimental skin (since Etherpad 1.8), candidate to
* become the default in Etherpad 2.0
*/
"skinName": "colibris",
//IP and port which etherpad should bind at
"ip": "0.0.0.0",
"port" : 9001,
......@@ -78,7 +91,7 @@
/* This setting is used if you require authentication of all users.
Note: /admin always requires authentication. */
"requireAuthentication" : false,
"requireAuthentication" : true,
/* Require authorization by a module, or a user with is_admin set, see below. */
"requireAuthorization" : false,
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment