From 73e265d8fd050ae3daa67472b4465a5c49d68910 Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Sat, 20 Apr 2019 15:18:33 +0300 Subject: Include to Wekan packages directory contents, so that meteor command would build all directly. This also simplifies build scripts. Thanks to xet7 ! --- packages/meteor-accounts-cas/.gitignore | 2 + packages/meteor-accounts-cas/LICENSE | 21 ++ packages/meteor-accounts-cas/README.md | 88 +++++++ packages/meteor-accounts-cas/cas_client.js | 112 ++++++++ packages/meteor-accounts-cas/cas_client_cordova.js | 71 ++++++ packages/meteor-accounts-cas/cas_server.js | 281 +++++++++++++++++++++ packages/meteor-accounts-cas/package.js | 29 +++ 7 files changed, 604 insertions(+) create mode 100644 packages/meteor-accounts-cas/.gitignore create mode 100644 packages/meteor-accounts-cas/LICENSE create mode 100644 packages/meteor-accounts-cas/README.md create mode 100644 packages/meteor-accounts-cas/cas_client.js create mode 100644 packages/meteor-accounts-cas/cas_client_cordova.js create mode 100644 packages/meteor-accounts-cas/cas_server.js create mode 100644 packages/meteor-accounts-cas/package.js (limited to 'packages/meteor-accounts-cas') diff --git a/packages/meteor-accounts-cas/.gitignore b/packages/meteor-accounts-cas/.gitignore new file mode 100644 index 00000000..bed7713f --- /dev/null +++ b/packages/meteor-accounts-cas/.gitignore @@ -0,0 +1,2 @@ +.build* +node_modules/ diff --git a/packages/meteor-accounts-cas/LICENSE b/packages/meteor-accounts-cas/LICENSE new file mode 100644 index 00000000..c2d69158 --- /dev/null +++ b/packages/meteor-accounts-cas/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2019 The Wekan Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/meteor-accounts-cas/README.md b/packages/meteor-accounts-cas/README.md new file mode 100644 index 00000000..3e246c4f --- /dev/null +++ b/packages/meteor-accounts-cas/README.md @@ -0,0 +1,88 @@ +This is a merged repository of useful forks of: atoy40:accounts-cas +=================== +([(https://atmospherejs.com/atoy40/accounts-cas](https://atmospherejs.com/atoy40/accounts-cas)) + +## Essential improvements by ppoulard to atoy40 and xaionaro versions + +* Added support of CAS attributes + +With this plugin, you can pick CAS attributes : https://github.com/joshchan/node-cas/wiki/CAS-Attributes + +Moved to Wekan GitHub org from from https://github.com/ppoulard/meteor-accounts-cas + +## Install + +``` +cd ~site +mkdir packages +cd packages +git clone https://github.com/wekan/meteor-accounts-cas +cd ~site +meteor add wekan:accounts-cas +``` + +## Usage + +Put CAS settings in Meteor.settings (for example using METEOR_SETTINGS env or --settings) like so: + +If casVersion is not defined, it will assume you use CAS 1.0. (note by xaionaro: option `casVersion` seems to be just ignored in the code, ATM). + +Server side settings: + +``` +Meteor.settings = { + "cas": { + "baseUrl": "https://cas.example.com/cas", + "autoClose": true, + "validateUrl":"https://cas.example.com/cas/p3/serviceValidate", + "casVersion": 3.0, + "attributes": { + "debug" : true + } + }, +} +``` + +CAS `attributes` settings : + +* `attributes`: by default `{}` : all default values below will apply +* * `debug` : by default `false` ; `true` will print to the server console the CAS attribute names to map, the CAS attributes values retrieved, if necessary the new user account created, and finally the user to use +* * `id` : by default, the CAS user is used for the user account, but you can specified another CAS attribute +* * `firstname` : by default `cas:givenName` ; but you can use your own CAS attribute +* * `lastname` : by default `cas:sn` (respectively) ; but you can use your own CAS attribute +* * `fullname` : by default unused, but if you specify your own CAS attribute, it will be used instead of the `firstname` + `lastname` +* * `mail` : by default `cas:mail` + +Client side settings: + +``` +Meteor.settings = { + "public": { + "cas": { + "loginUrl": "https://cas.example.com/login", + "serviceParam": "service", + "popupWidth": 810, + "popupHeight": 610, + "popup": true, + } + } +} +``` + +`proxyUrl` is not required. Setup [ROOT_URL](http://docs.meteor.com/api/core.html#Meteor-absoluteUrl) environment variable instead. + +Then, to start authentication, you have to call the following method from the client (for example in a click handler) : + +``` +Meteor.loginWithCas([callback]); +``` + +It must open a popup containing you CAS login form or redirect to the CAS login form (depending on "popup" setting). + +If popup is disabled (== false), then it's required to execute `Meteor.initCas([callback])` in `Meteor.startup` of the client side. ATM, `Meteor.initCas()` completes authentication. + +## Examples + +* [https://devel.mephi.ru/dyokunev/start-mephi-ru](https://devel.mephi.ru/dyokunev/start-mephi-ru) + + diff --git a/packages/meteor-accounts-cas/cas_client.js b/packages/meteor-accounts-cas/cas_client.js new file mode 100644 index 00000000..bd94be6b --- /dev/null +++ b/packages/meteor-accounts-cas/cas_client.js @@ -0,0 +1,112 @@ + +function addParameterToURL(url, param){ + var urlSplit = url.split('?'); + return url+(urlSplit.length>0 ? '?':'&') + param; +} + +Meteor.initCas = function(callback) { + const casTokenMatch = window.location.href.match(/[?&]casToken=([^&]+)/); + if (casTokenMatch == null) { + return; + } + + window.history.pushState('', document.title, window.location.href.replace(/([&?])casToken=[^&]+[&]?/, '$1').replace(/[?&]+$/g, '')); + + Accounts.callLoginMethod({ + methodArguments: [{ cas: { credentialToken: casTokenMatch[1] } }], + userCallback: function(err){ + if (err == null) { + // should we do anything on success? + } + if (callback != null) { + callback(err); + } + } + }); +} + +Meteor.loginWithCas = function(options, callback) { + + var credentialToken = Random.id(); + + if (!Meteor.settings.public && + !Meteor.settings.public.cas && + !Meteor.settings.public.cas.loginUrl) { + return; + } + + var settings = Meteor.settings.public.cas; + + var backURL = window.location.href.replace('#', ''); + if (options != null && options.redirectUrl != null) + backURL = options.redirectUrl; + + var serviceURL = addParameterToURL(backURL, 'casToken='+credentialToken); + + var loginUrl = settings.loginUrl + + "?" + (settings.serviceParam || "service") + "=" + + encodeURIComponent(serviceURL) + + if (settings.popup == false) { + window.location = loginUrl; + return; + } + + var popup = openCenteredPopup( + loginUrl, + settings.width || 800, + settings.height || 600 + ); + + var checkPopupOpen = setInterval(function() { + try { + if(popup && popup.document && popup.document.getElementById('popupCanBeClosed')) { + popup.close(); + } + // Fix for #328 - added a second test criteria (popup.closed === undefined) + // to humour this Android quirk: + // http://code.google.com/p/android/issues/detail?id=21061 + var popupClosed = popup.closed || popup.closed === undefined; + } catch (e) { + // For some unknown reason, IE9 (and others?) sometimes (when + // the popup closes too quickly?) throws "SCRIPT16386: No such + // interface supported" when trying to read 'popup.closed'. Try + // again in 100ms. + return; + } + + if (popupClosed) { + clearInterval(checkPopupOpen); + + // check auth on server. + Accounts.callLoginMethod({ + methodArguments: [{ cas: { credentialToken: credentialToken } }], + userCallback: callback + }); + } + }, 100); +}; + +var openCenteredPopup = function(url, width, height) { + var screenX = typeof window.screenX !== 'undefined' + ? window.screenX : window.screenLeft; + var screenY = typeof window.screenY !== 'undefined' + ? window.screenY : window.screenTop; + var outerWidth = typeof window.outerWidth !== 'undefined' + ? window.outerWidth : document.body.clientWidth; + var outerHeight = typeof window.outerHeight !== 'undefined' + ? window.outerHeight : (document.body.clientHeight - 22); + // XXX what is the 22? + + // Use `outerWidth - width` and `outerHeight - height` for help in + // positioning the popup centered relative to the current window + var left = screenX + (outerWidth - width) / 2; + var top = screenY + (outerHeight - height) / 2; + var features = ('width=' + width + ',height=' + height + + ',left=' + left + ',top=' + top + ',scrollbars=yes'); + + var newwindow = window.open(url, '_blank', features); + if (newwindow.focus) + newwindow.focus(); + return newwindow; +}; diff --git a/packages/meteor-accounts-cas/cas_client_cordova.js b/packages/meteor-accounts-cas/cas_client_cordova.js new file mode 100644 index 00000000..c7f95b50 --- /dev/null +++ b/packages/meteor-accounts-cas/cas_client_cordova.js @@ -0,0 +1,71 @@ + +Meteor.loginWithCas = function(callback) { + + var credentialToken = Random.id(); + + if (!Meteor.settings.public && + !Meteor.settings.public.cas && + !Meteor.settings.public.cas.loginUrl) { + return; + } + + var settings = Meteor.settings.public.cas; + + var loginUrl = settings.loginUrl + + "?" + (settings.service || "service") + "=" + + Meteor.absoluteUrl('_cas/') + + credentialToken; + + + var fail = function (err) { + Meteor._debug("Error from OAuth popup: " + JSON.stringify(err)); + }; + + // When running on an android device, we sometimes see the + // `pageLoaded` callback fire twice for the final page in the OAuth + // popup, even though the page only loads once. This is maybe an + // Android bug or maybe something intentional about how onPageFinished + // works that we don't understand and isn't well-documented. + var oauthFinished = false; + + var pageLoaded = function (event) { + if (oauthFinished) { + return; + } + + if (event.url.indexOf(Meteor.absoluteUrl('_cas')) === 0) { + + oauthFinished = true; + + // On iOS, this seems to prevent "Warning: Attempt to dismiss from + // view controller while a presentation + // or dismiss is in progress". My guess is that the last + // navigation of the OAuth popup is still in progress while we try + // to close the popup. See + // https://issues.apache.org/jira/browse/CB-2285. + // + // XXX Can we make this timeout smaller? + setTimeout(function () { + popup.close(); + // check auth on server. + Accounts.callLoginMethod({ + methodArguments: [{ cas: { credentialToken: credentialToken } }], + userCallback: callback + }); + }, 100); + } + }; + + var onExit = function () { + popup.removeEventListener('loadstop', pageLoaded); + popup.removeEventListener('loaderror', fail); + popup.removeEventListener('exit', onExit); + }; + + var popup = window.open(loginUrl, '_blank', 'location=no,hidden=no'); + popup.addEventListener('loadstop', pageLoaded); + popup.addEventListener('loaderror', fail); + popup.addEventListener('exit', onExit); + popup.show(); + +}; \ No newline at end of file diff --git a/packages/meteor-accounts-cas/cas_server.js b/packages/meteor-accounts-cas/cas_server.js new file mode 100644 index 00000000..15c1b174 --- /dev/null +++ b/packages/meteor-accounts-cas/cas_server.js @@ -0,0 +1,281 @@ +"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 = 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); + } +} diff --git a/packages/meteor-accounts-cas/package.js b/packages/meteor-accounts-cas/package.js new file mode 100644 index 00000000..670fe687 --- /dev/null +++ b/packages/meteor-accounts-cas/package.js @@ -0,0 +1,29 @@ +Package.describe({ + summary: "CAS support for accounts", + version: "0.1.0", + name: "wekan:accounts-cas", + git: "https://github.com/wekan/meteor-accounts-cas" +}); + +Package.onUse(function(api) { + api.versionsFrom('METEOR@1.3.5.1'); + api.use('routepolicy', 'server'); + api.use('webapp', 'server'); + api.use('accounts-base', ['client', 'server']); + // Export Accounts (etc) to packages using this one. + api.imply('accounts-base', ['client', 'server']); + api.use('underscore'); + api.add_files('cas_client.js', 'web.browser'); + api.add_files('cas_client_cordova.js', 'web.cordova'); + api.add_files('cas_server.js', 'server'); + +}); + +Npm.depends({ + xml2js: "0.4.17", + cas: "https://github.com/anrizal/node-cas/tarball/2baed530842e7a437f8f71b9346bcac8e84773cc" +}); + +Cordova.depends({ + 'cordova-plugin-inappbrowser': '1.2.0' +}); -- cgit v1.2.3-1-g7c22