The Open Source kanban (built with Meteor). Keep variable/table/field names camelCase. For translations, only add Pull Request changes to wekan/i18n/en.i18n.json , other translations are done at https://transifex.com/wekan/wekan only.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wekan/packages/wekan-accounts-cas/cas_server.js

282 lines
8.1 KiB

"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']) {
var userData = {
id: result['cas:serviceResponse']['cas:authenticationSuccess'][0]['cas:user'][0].toLowerCase(),
}
const attributes = result['cas:serviceResponse']['cas:authenticationSuccess'][0]['cas:attributes'][0];
for (var fieldName in attributes) {
userData[fieldName] = attributes[fieldName][0];
};
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 = Users.findOne({ 'username': options.username });
if (! user) {
if (attrs.debug) {
console.log(`Creating user account ${JSON.stringify(options)}`);
}
const userId = Accounts.insertUserDoc({}, options);
user = 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 = '<html><body><div id="popupCanBeClosed"></div></body></html>';
res.end(content, 'utf-8');
}
const redirect = (res, whereTo) => {
res.writeHead(302, {'Location': whereTo});
const content = '<html><head><meta http-equiv="refresh" content="0; url='+whereTo+'" /></head><body>Redirection to <a href='+whereTo+'>'+whereTo+'</a></body></html>';
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);
}
}