"use strict"; const Fiber = Npm.require('fibers'); const https = Npm.require('https'); const url = Npm.require('url'); const xmlParser = Npm.require('xml2js'); // Library class CAS { constructor(options) { options = options || {}; if (!options.validate_url) { throw new Error('Required CAS option `validateUrl` missing.'); } if (!options.service) { throw new Error('Required CAS option `service` missing.'); } const cas_url = url.parse(options.validate_url); if (cas_url.protocol != 'https:' ) { throw new Error('Only https CAS servers are supported.'); } else if (!cas_url.hostname) { throw new Error('Option `validateUrl` must be a valid url like: https://example.com/cas/serviceValidate'); } else { this.hostname = cas_url.host; this.port = 443;// Should be 443 for https this.validate_path = cas_url.pathname; } this.service = options.service; } validate(ticket, callback) { const httparams = { host: this.hostname, port: this.port, path: url.format({ pathname: this.validate_path, query: {ticket: ticket, service: this.service}, }), }; https.get(httparams, (res) => { res.on('error', (e) => { console.log('error' + e); callback(e); }); // Read result res.setEncoding('utf8'); let response = ''; res.on('data', (chunk) => { response += chunk; }); res.on('end', (error) => { if (error) { console.log('error callback'); console.log(error); callback(undefined, false); } else { xmlParser.parseString(response, (err, result) => { if (err) { console.log('Bad response format.'); callback({message: 'Bad response format. XML could not parse it'}); } else { if (result['cas:serviceResponse'] == null) { console.log('Empty response.'); callback({message: 'Empty response.'}); } if (result['cas:serviceResponse']['cas:authenticationSuccess']) { const userData = { id: result['cas:serviceResponse']['cas:authenticationSuccess'][0]['cas:user'][0].toLowerCase(), }; const attributes = result['cas:serviceResponse']['cas:authenticationSuccess'][0]['cas:attributes'][0]; // Check allowed ldap groups if exist (array only) // example cas settings : "allowedLdapGroups" : ["wekan", "admin"], let findedGroup = false; const allowedLdapGroups = Meteor.settings.cas.allowedLdapGroups || false; for (const fieldName in attributes) { if (allowedLdapGroups && fieldName === 'cas:memberOf') { for (const groups in attributes[fieldName]) { const str = attributes[fieldName][groups]; if (!Array.isArray(allowedLdapGroups)) { callback({message: 'Settings "allowedLdapGroups" must be an array'}); } for (const allowedLdapGroup in allowedLdapGroups) { if (str.search(`cn=${allowedLdapGroups[allowedLdapGroup]}`) >= 0) { findedGroup = true; } } } } userData[fieldName] = attributes[fieldName][0]; } if (allowedLdapGroups && !findedGroup) { callback({message: 'Group not finded.'}, false); } else { callback(undefined, true, userData); } } else { callback(undefined, false); } } }); } }); }); } } ////// END OF CAS MODULE let _casCredentialTokens = {}; let _userData = {}; //RoutePolicy.declare('/_cas/', 'network'); // Listen to incoming OAuth http requests WebApp.connectHandlers.use((req, res, next) => { // Need to create a Fiber since we're using synchronous http calls and nothing // else is wrapping this in a fiber automatically Fiber(() => { middleware(req, res, next); }).run(); }); const middleware = (req, res, next) => { // Make sure to catch any exceptions because otherwise we'd crash // the runner try { urlParsed = url.parse(req.url, true); // Getting the ticket (if it's defined in GET-params) // If no ticket, then request will continue down the default // middlewares. const query = urlParsed.query; if (query == null) { next(); return; } const ticket = query.ticket; if (ticket == null) { next(); return; } const serviceUrl = Meteor.absoluteUrl(urlParsed.href.replace(/^\//g, '')).replace(/([&?])ticket=[^&]+[&]?/g, '$1').replace(/[?&]+$/g, ''); const redirectUrl = serviceUrl;//.replace(/([&?])casToken=[^&]+[&]?/g, '$1').replace(/[?&]+$/g, ''); // get auth token const credentialToken = query.casToken; if (!credentialToken) { end(res, redirectUrl); return; } // validate ticket casValidate(req, ticket, credentialToken, serviceUrl, () => { end(res, redirectUrl); }); } catch (err) { console.log("account-cas: unexpected error : " + err.message); end(res, redirectUrl); } }; const casValidate = (req, ticket, token, service, callback) => { // get configuration if (!Meteor.settings.cas/* || !Meteor.settings.cas.validate*/) { throw new Error('accounts-cas: unable to get configuration.'); } const cas = new CAS({ validate_url: Meteor.settings.cas.validateUrl, service: service, version: Meteor.settings.cas.casVersion }); cas.validate(ticket, (err, status, userData) => { if (err) { console.log("accounts-cas: error when trying to validate " + err); console.log(err); } else { if (status) { console.log(`accounts-cas: user validated ${userData.id} (${JSON.stringify(userData)})`); _casCredentialTokens[token] = { id: userData.id }; _userData = userData; } else { console.log("accounts-cas: unable to validate " + ticket); } } callback(); }); return; }; /* * Register a server-side login handle. * It is call after Accounts.callLoginMethod() is call from client. */ Accounts.registerLoginHandler((options) => { if (!options.cas) return undefined; if (!_hasCredential(options.cas.credentialToken)) { throw new Meteor.Error(Accounts.LoginCancelledError.numericError, 'no matching login attempt found'); } const result = _retrieveCredential(options.cas.credentialToken); const attrs = Meteor.settings.cas.attributes || {}; // CAS keys const fn = attrs.firstname || 'cas:givenName'; const ln = attrs.lastname || 'cas:sn'; const full = attrs.fullname; const mail = attrs.mail || 'cas:mail'; // or 'email' const uid = attrs.id || 'id'; if (attrs.debug) { if (full) { console.log(`CAS fields : id:"${uid}", fullname:"${full}", mail:"${mail}"`); } else { console.log(`CAS fields : id:"${uid}", firstname:"${fn}", lastname:"${ln}", mail:"${mail}"`); } } const name = full ? _userData[full] : _userData[fn] + ' ' + _userData[ln]; // https://docs.meteor.com/api/accounts.html#Meteor-users options = { // _id: Meteor.userId() username: _userData[uid], // Unique name emails: [ { address: _userData[mail], verified: true } ], createdAt: new Date(), profile: { // The profile is writable by the user by default. name: name, fullname : name, email : _userData[mail] }, active: true, globalRoles: ['user'] }; if (attrs.debug) { console.log(`CAS response : ${JSON.stringify(result)}`); } let user = Meteor.users.findOne({ 'username': options.username }); if (! user) { if (attrs.debug) { console.log(`Creating user account ${JSON.stringify(options)}`); } const userId = Accounts.insertUserDoc({}, options); user = Meteor.users.findOne(userId); } if (attrs.debug) { console.log(`Using user account ${JSON.stringify(user)}`); } return { userId: user._id }; }); const _hasCredential = (credentialToken) => { return _.has(_casCredentialTokens, credentialToken); } /* * Retrieve token and delete it to avoid replaying it. */ const _retrieveCredential = (credentialToken) => { const result = _casCredentialTokens[credentialToken]; delete _casCredentialTokens[credentialToken]; return result; } const closePopup = (res) => { if (Meteor.settings.cas && Meteor.settings.cas.popup == false) { return; } res.writeHead(200, {'Content-Type': 'text/html'}); const content = '
'; res.end(content, 'utf-8'); } const redirect = (res, whereTo) => { res.writeHead(302, {'Location': whereTo}); const content = 'Redirection to '+whereTo+''; res.end(content, 'utf-8'); return } const end = (res, whereTo) => { if (Meteor.settings.cas && Meteor.settings.cas.popup == false) { redirect(res, whereTo); } else { closePopup(res); } }