mirror of
https://github.com/wekan/wekan.git
synced 2025-04-23 13:37:09 -04:00
Added back WeKan lockout, ldap, oidc, cas.
Thanks to xet7 !
This commit is contained in:
parent
a73a4c1e5b
commit
00768b4392
45 changed files with 3966 additions and 0 deletions
|
@ -142,3 +142,8 @@ service-configuration@1.3.0
|
|||
communitypackages:picker
|
||||
simple:rest-accounts-password
|
||||
wekan-accounts-sandstorm
|
||||
wekan-accounts-lockout
|
||||
wekan-oidc
|
||||
wekan-accounts-oidc
|
||||
wekan-ldap
|
||||
wekan-accounts-cas
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
accounts-base@2.2.3
|
||||
accounts-oauth@1.4.1
|
||||
accounts-password@2.3.1
|
||||
aldeed:collection2@2.10.0
|
||||
aldeed:collection2-core@1.2.0
|
||||
|
@ -119,6 +120,8 @@ mquandalle:jquery-textcomplete@0.8.0_1
|
|||
mquandalle:mousetrap-bindglobal@0.0.1
|
||||
msavin:usercache@1.8.0
|
||||
npm-mongo@4.3.1
|
||||
oauth@2.1.2
|
||||
oauth2@1.3.1
|
||||
observe-sequence@1.0.20
|
||||
ongoworks:speakingurl@1.1.0
|
||||
ordered-dict@1.1.0
|
||||
|
@ -224,6 +227,12 @@ useraccounts:flow-routing@1.15.0
|
|||
useraccounts:unstyled@1.14.2
|
||||
webapp@1.13.1
|
||||
webapp-hashing@1.1.0
|
||||
wekan-accounts-cas@0.1.0
|
||||
wekan-accounts-lockout@1.0.0
|
||||
wekan-accounts-oidc@1.0.10
|
||||
wekan-accounts-sandstorm@0.8.0
|
||||
wekan-ldap@0.0.2
|
||||
wekan-markdown@1.0.9
|
||||
wekan-oidc@1.0.12
|
||||
yasaricli:slugify@0.0.7
|
||||
zimme:active-route@2.3.2
|
||||
|
|
2
packages/wekan-accounts-cas/.gitignore
vendored
Normal file
2
packages/wekan-accounts-cas/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
.build*
|
||||
node_modules/
|
21
packages/wekan-accounts-cas/LICENSE
Normal file
21
packages/wekan-accounts-cas/LICENSE
Normal file
|
@ -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.
|
88
packages/wekan-accounts-cas/README.md
Normal file
88
packages/wekan-accounts-cas/README.md
Normal file
|
@ -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)
|
||||
|
||||
|
117
packages/wekan-accounts-cas/cas_client.js
Normal file
117
packages/wekan-accounts-cas/cas_client.js
Normal file
|
@ -0,0 +1,117 @@
|
|||
|
||||
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: err => {
|
||||
// Fix redirect bug after login successfully
|
||||
if (!err) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 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;
|
||||
};
|
71
packages/wekan-accounts-cas/cas_client_cordova.js
Normal file
71
packages/wekan-accounts-cas/cas_client_cordova.js
Normal file
|
@ -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 <MainViewController: ...> 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();
|
||||
|
||||
};
|
304
packages/wekan-accounts-cas/cas_server.js
Normal file
304
packages/wekan-accounts-cas/cas_server.js
Normal file
|
@ -0,0 +1,304 @@
|
|||
"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 = '<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);
|
||||
}
|
||||
}
|
29
packages/wekan-accounts-cas/package.js
Normal file
29
packages/wekan-accounts-cas/package.js
Normal file
|
@ -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('2.7');
|
||||
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.addFiles('cas_client.js', 'web.browser');
|
||||
api.addFiles('cas_client_cordova.js', 'web.cordova');
|
||||
api.addFiles('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'
|
||||
});
|
25
packages/wekan-accounts-lockout/CONTRIBUTING.md
Normal file
25
packages/wekan-accounts-lockout/CONTRIBUTING.md
Normal file
|
@ -0,0 +1,25 @@
|
|||
# Contributing guide
|
||||
|
||||
Want to contribute to Accounts-Lockout? Awesome!
|
||||
There are many ways you can contribute, see below.
|
||||
|
||||
## Opening issues
|
||||
|
||||
Open an issue to report bugs or to propose new features.
|
||||
|
||||
- Reporting bugs: describe the bug as clearly as you can, including steps to reproduce, what happened and what you were expecting to happen. Also include browser version, OS and other related software's (npm, Node.js, etc) versions when applicable.
|
||||
|
||||
- Proposing features: explain the proposed feature, what it should do, why it is useful, how users should use it. Give us as much info as possible so it will be easier to discuss, access and implement the proposed feature. When you're unsure about a certain aspect of the feature, feel free to leave it open for others to discuss and find an appropriate solution.
|
||||
|
||||
## Proposing pull requests
|
||||
|
||||
Pull requests are very welcome. Note that if you are going to propose drastic changes, be sure to open an issue for discussion first, to make sure that your PR will be accepted before you spend effort coding it.
|
||||
|
||||
Fork the Accounts-Lockout repository, clone it locally and create a branch for your proposed bug fix or new feature. Avoid working directly on the master branch.
|
||||
|
||||
Implement your bug fix or feature, write tests to cover it and make sure all tests are passing (run a final `npm test` to make sure everything is correct). Then commit your changes, push your bug fix/feature branch to the origin (your forked repo) and open a pull request to the upstream (the repository you originally forked)'s master branch.
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation is extremely important and takes a fair deal of time and effort to write and keep updated.
|
||||
Please submit any and all improvements you can make to the repository's docs.
|
21
packages/wekan-accounts-lockout/LICENSE
Normal file
21
packages/wekan-accounts-lockout/LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2017 Lucas Antoniassi de Paiva
|
||||
|
||||
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.
|
126
packages/wekan-accounts-lockout/README.md
Normal file
126
packages/wekan-accounts-lockout/README.md
Normal file
|
@ -0,0 +1,126 @@
|
|||
# Meteor - Accounts - Lockout
|
||||
|
||||
[](https://travis-ci.org/LucasAntoniassi/meteor-accounts-lockout)
|
||||
[](https://www.codacy.com/app/lucasantoniassi/meteor-accounts-lockout?utm_source=github.com&utm_medium=referral&utm_content=LucasAntoniassi/meteor-accounts-lockout&utm_campaign=Badge_Grade)
|
||||
[](https://codeclimate.com/github/LucasAntoniassi/meteor-accounts-lockout)
|
||||
|
||||
## What it is
|
||||
|
||||
Seamless Meteor apps accounts protection from password brute-force attacks.
|
||||
Users won't notice it. Hackers shall not pass.
|
||||
|
||||

|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
meteor add lucasantoniassi:accounts-lockout
|
||||
```
|
||||
|
||||
## Usage via ES6 import
|
||||
|
||||
```javascript
|
||||
// server
|
||||
import { AccountsLockout } from 'meteor/lucasantoniassi:accounts-lockout';
|
||||
```
|
||||
|
||||
## How to use
|
||||
|
||||
Default settings:
|
||||
|
||||
```javascript
|
||||
"knownUsers": {
|
||||
"failuresBeforeLockout": 3, // positive integer greater than 0
|
||||
"lockoutPeriod": 60, // in seconds
|
||||
"failureWindow": 10 // in seconds
|
||||
},
|
||||
"unknownUsers": {
|
||||
"failuresBeforeLockout": 3, // positive integer greater than 0
|
||||
"lockoutPeriod": 60, // in seconds
|
||||
"failureWindow": 10 // in seconds
|
||||
}
|
||||
```
|
||||
|
||||
`knownUsers` are users where already belongs to your `Meteor.users` collections,
|
||||
these rules are applied if they attempt to login with an incorrect password but a know email.
|
||||
|
||||
`unknownUsers` are users where **not** belongs to your `Meteor.users` collections,
|
||||
these rules are applied if they attempt to login with a unknown email.
|
||||
|
||||
`failuresBeforeLockout` should be a positive integer greater than 0.
|
||||
|
||||
`lockoutPeriod` should be in seconds.
|
||||
|
||||
`failureWindow` should be in seconds.
|
||||
|
||||
If the `default` is nice to you, you can do that.
|
||||
|
||||
```javascript
|
||||
(new AccountsLockout()).startup();
|
||||
```
|
||||
|
||||
You can overwrite passing an `object` as argument.
|
||||
|
||||
```javascript
|
||||
(new AccountsLockout({
|
||||
knownUsers: {
|
||||
failuresBeforeLockout: 3,
|
||||
lockoutPeriod: 60,
|
||||
failureWindow: 15,
|
||||
},
|
||||
unknownUsers: {
|
||||
failuresBeforeLockout: 3,
|
||||
lockoutPeriod: 60,
|
||||
failureWindow: 15,
|
||||
},
|
||||
})).startup();
|
||||
```
|
||||
|
||||
If you prefer, you can pass a `function` as argument.
|
||||
|
||||
```javascript
|
||||
const knownUsersRules = (user) => {
|
||||
// apply some logic with this user
|
||||
return {
|
||||
failuresBeforeLockout,
|
||||
lockoutPeriod,
|
||||
failureWindow,
|
||||
};
|
||||
};
|
||||
|
||||
const unknownUsersRules = (connection) => {
|
||||
// apply some logic with this connection
|
||||
return {
|
||||
failuresBeforeLockout,
|
||||
lockoutPeriod,
|
||||
failureWindow,
|
||||
};
|
||||
};
|
||||
|
||||
(new AccountsLockout({
|
||||
knownUsers: knownUsersRules,
|
||||
unknownUsers: unknownUsersRules,
|
||||
})).startup();
|
||||
```
|
||||
|
||||
If you prefer, you can use `Meteor.settings`. It will overwrite any previous case.
|
||||
|
||||
```javascript
|
||||
"accounts-lockout": {
|
||||
"knownUsers": {
|
||||
"failuresBeforeLockout": 3,
|
||||
"lockoutPeriod": 60,
|
||||
"failureWindow": 10
|
||||
},
|
||||
"unknownUsers": {
|
||||
"failuresBeforeLockout": 3,
|
||||
"lockoutPeriod": 60,
|
||||
"failureWindow": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This package is open-sourced software licensed under the [MIT license](http://opensource.org/licenses/MIT).
|
||||
|
5
packages/wekan-accounts-lockout/accounts-lockout.js
Normal file
5
packages/wekan-accounts-lockout/accounts-lockout.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
import AccountsLockout from './src/accountsLockout';
|
||||
|
||||
const Name = 'wekan-accounts-lockout';
|
||||
|
||||
export { Name, AccountsLockout };
|
18
packages/wekan-accounts-lockout/package.js
Normal file
18
packages/wekan-accounts-lockout/package.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
/* global Package */
|
||||
|
||||
Package.describe({
|
||||
name: 'wekan-accounts-lockout',
|
||||
version: '1.0.0',
|
||||
summary: 'Meteor package for locking user accounts and stopping brute force attacks',
|
||||
git: 'https://github.com/lucasantoniassi/meteor-accounts-lockout.git',
|
||||
documentation: 'README.md',
|
||||
});
|
||||
|
||||
Package.onUse((api) => {
|
||||
api.versionsFrom('2.7');
|
||||
api.use([
|
||||
'ecmascript',
|
||||
'accounts-password',
|
||||
]);
|
||||
api.mainModule('accounts-lockout.js');
|
||||
});
|
4
packages/wekan-accounts-lockout/package.json
Normal file
4
packages/wekan-accounts-lockout/package.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"name": "wekan-accounts-lockout",
|
||||
"private": true
|
||||
}
|
29
packages/wekan-accounts-lockout/src/accountsLockout.js
Normal file
29
packages/wekan-accounts-lockout/src/accountsLockout.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
import KnownUser from './knownUser';
|
||||
import UnknownUser from './unknownUser';
|
||||
|
||||
class AccountsLockout {
|
||||
constructor({
|
||||
knownUsers = {
|
||||
failuresBeforeLockout: 3,
|
||||
lockoutPeriod: 60,
|
||||
failureWindow: 15,
|
||||
},
|
||||
unknownUsers = {
|
||||
failuresBeforeLockout: 3,
|
||||
lockoutPeriod: 60,
|
||||
failureWindow: 15,
|
||||
},
|
||||
}) {
|
||||
this.settings = {
|
||||
knownUsers,
|
||||
unknownUsers,
|
||||
};
|
||||
}
|
||||
|
||||
startup() {
|
||||
(new KnownUser(this.settings.knownUsers)).startup();
|
||||
(new UnknownUser(this.settings.unknownUsers)).startup();
|
||||
}
|
||||
}
|
||||
|
||||
export default AccountsLockout;
|
|
@ -0,0 +1,3 @@
|
|||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
export default new Meteor.Collection('AccountsLockout.Connections');
|
326
packages/wekan-accounts-lockout/src/knownUser.js
Normal file
326
packages/wekan-accounts-lockout/src/knownUser.js
Normal file
|
@ -0,0 +1,326 @@
|
|||
/* eslint-disable no-underscore-dangle */
|
||||
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
|
||||
class KnownUser {
|
||||
constructor(settings) {
|
||||
this.unchangedSettings = settings;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
startup() {
|
||||
if (!(this.unchangedSettings instanceof Function)) {
|
||||
this.updateSettings();
|
||||
}
|
||||
this.scheduleUnlocksForLockedAccounts();
|
||||
KnownUser.unlockAccountsIfLockoutAlreadyExpired();
|
||||
this.hookIntoAccounts();
|
||||
}
|
||||
|
||||
updateSettings() {
|
||||
const settings = KnownUser.knownUsers();
|
||||
if (settings) {
|
||||
settings.forEach(function updateSetting({ key, value }) {
|
||||
this.settings[key] = value;
|
||||
});
|
||||
}
|
||||
this.validateSettings();
|
||||
}
|
||||
|
||||
validateSettings() {
|
||||
if (
|
||||
!this.settings.failuresBeforeLockout ||
|
||||
this.settings.failuresBeforeLockout < 0
|
||||
) {
|
||||
throw new Error('"failuresBeforeLockout" is not positive integer');
|
||||
}
|
||||
if (
|
||||
!this.settings.lockoutPeriod ||
|
||||
this.settings.lockoutPeriod < 0
|
||||
) {
|
||||
throw new Error('"lockoutPeriod" is not positive integer');
|
||||
}
|
||||
if (
|
||||
!this.settings.failureWindow ||
|
||||
this.settings.failureWindow < 0
|
||||
) {
|
||||
throw new Error('"failureWindow" is not positive integer');
|
||||
}
|
||||
}
|
||||
|
||||
scheduleUnlocksForLockedAccounts() {
|
||||
const lockedAccountsCursor = Meteor.users.find(
|
||||
{
|
||||
'services.accounts-lockout.unlockTime': {
|
||||
$gt: Number(new Date()),
|
||||
},
|
||||
},
|
||||
{
|
||||
fields: {
|
||||
'services.accounts-lockout.unlockTime': 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
const currentTime = Number(new Date());
|
||||
lockedAccountsCursor.forEach((user) => {
|
||||
let lockDuration = KnownUser.unlockTime(user) - currentTime;
|
||||
if (lockDuration >= this.settings.lockoutPeriod) {
|
||||
lockDuration = this.settings.lockoutPeriod * 1000;
|
||||
}
|
||||
if (lockDuration <= 1) {
|
||||
lockDuration = 1;
|
||||
}
|
||||
Meteor.setTimeout(
|
||||
KnownUser.unlockAccount.bind(null, user._id),
|
||||
lockDuration,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static unlockAccountsIfLockoutAlreadyExpired() {
|
||||
const currentTime = Number(new Date());
|
||||
const query = {
|
||||
'services.accounts-lockout.unlockTime': {
|
||||
$lt: currentTime,
|
||||
},
|
||||
};
|
||||
const data = {
|
||||
$unset: {
|
||||
'services.accounts-lockout.unlockTime': 0,
|
||||
'services.accounts-lockout.failedAttempts': 0,
|
||||
},
|
||||
};
|
||||
Meteor.users.update(query, data);
|
||||
}
|
||||
|
||||
hookIntoAccounts() {
|
||||
Accounts.validateLoginAttempt(this.validateLoginAttempt.bind(this));
|
||||
Accounts.onLogin(KnownUser.onLogin);
|
||||
}
|
||||
|
||||
|
||||
validateLoginAttempt(loginInfo) {
|
||||
if (
|
||||
// don't interrupt non-password logins
|
||||
loginInfo.type !== 'password' ||
|
||||
loginInfo.user === undefined ||
|
||||
// Don't handle errors unless they are due to incorrect password
|
||||
(loginInfo.error !== undefined && loginInfo.error.reason !== 'Incorrect password')
|
||||
) {
|
||||
return loginInfo.allowed;
|
||||
}
|
||||
|
||||
// If there was no login error and the account is NOT locked, don't interrupt
|
||||
const unlockTime = KnownUser.unlockTime(loginInfo.user);
|
||||
if (loginInfo.error === undefined && unlockTime === 0) {
|
||||
return loginInfo.allowed;
|
||||
}
|
||||
|
||||
if (this.unchangedSettings instanceof Function) {
|
||||
this.settings = this.unchangedSettings(loginInfo.user);
|
||||
this.validateSettings();
|
||||
}
|
||||
|
||||
const userId = loginInfo.user._id;
|
||||
let failedAttempts = 1 + KnownUser.failedAttempts(loginInfo.user);
|
||||
const firstFailedAttempt = KnownUser.firstFailedAttempt(loginInfo.user);
|
||||
const currentTime = Number(new Date());
|
||||
|
||||
const canReset = (currentTime - firstFailedAttempt) > (1000 * this.settings.failureWindow);
|
||||
if (canReset) {
|
||||
failedAttempts = 1;
|
||||
KnownUser.resetAttempts(failedAttempts, userId);
|
||||
}
|
||||
|
||||
const canIncrement = failedAttempts < this.settings.failuresBeforeLockout;
|
||||
if (canIncrement) {
|
||||
KnownUser.incrementAttempts(failedAttempts, userId);
|
||||
}
|
||||
|
||||
const maxAttemptsAllowed = this.settings.failuresBeforeLockout;
|
||||
const attemptsRemaining = maxAttemptsAllowed - failedAttempts;
|
||||
if (unlockTime > currentTime) {
|
||||
let duration = unlockTime - currentTime;
|
||||
duration = Math.ceil(duration / 1000);
|
||||
duration = duration > 1 ? duration : 1;
|
||||
KnownUser.tooManyAttempts(duration);
|
||||
}
|
||||
if (failedAttempts === maxAttemptsAllowed) {
|
||||
this.setNewUnlockTime(failedAttempts, userId);
|
||||
|
||||
let duration = this.settings.lockoutPeriod;
|
||||
duration = Math.ceil(duration);
|
||||
duration = duration > 1 ? duration : 1;
|
||||
return KnownUser.tooManyAttempts(duration);
|
||||
}
|
||||
return KnownUser.incorrectPassword(
|
||||
failedAttempts,
|
||||
maxAttemptsAllowed,
|
||||
attemptsRemaining,
|
||||
);
|
||||
}
|
||||
|
||||
static resetAttempts(
|
||||
failedAttempts,
|
||||
userId,
|
||||
) {
|
||||
const currentTime = Number(new Date());
|
||||
const query = { _id: userId };
|
||||
const data = {
|
||||
$set: {
|
||||
'services.accounts-lockout.failedAttempts': failedAttempts,
|
||||
'services.accounts-lockout.lastFailedAttempt': currentTime,
|
||||
'services.accounts-lockout.firstFailedAttempt': currentTime,
|
||||
},
|
||||
};
|
||||
Meteor.users.update(query, data);
|
||||
}
|
||||
|
||||
static incrementAttempts(
|
||||
failedAttempts,
|
||||
userId,
|
||||
) {
|
||||
const currentTime = Number(new Date());
|
||||
const query = { _id: userId };
|
||||
const data = {
|
||||
$set: {
|
||||
'services.accounts-lockout.failedAttempts': failedAttempts,
|
||||
'services.accounts-lockout.lastFailedAttempt': currentTime,
|
||||
},
|
||||
};
|
||||
Meteor.users.update(query, data);
|
||||
}
|
||||
|
||||
setNewUnlockTime(
|
||||
failedAttempts,
|
||||
userId,
|
||||
) {
|
||||
const currentTime = Number(new Date());
|
||||
const newUnlockTime = (1000 * this.settings.lockoutPeriod) + currentTime;
|
||||
const query = { _id: userId };
|
||||
const data = {
|
||||
$set: {
|
||||
'services.accounts-lockout.failedAttempts': failedAttempts,
|
||||
'services.accounts-lockout.lastFailedAttempt': currentTime,
|
||||
'services.accounts-lockout.unlockTime': newUnlockTime,
|
||||
},
|
||||
};
|
||||
Meteor.users.update(query, data);
|
||||
Meteor.setTimeout(
|
||||
KnownUser.unlockAccount.bind(null, userId),
|
||||
this.settings.lockoutPeriod * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
static onLogin(loginInfo) {
|
||||
//get the data from oidc login and remove again?
|
||||
if(loginInfo.type ==='oidc'){
|
||||
Meteor.call('groupRoutineOnLogin', loginInfo.user.services.oidc, loginInfo.user._id);
|
||||
return;
|
||||
}
|
||||
if (loginInfo.type !== 'password') {
|
||||
return;
|
||||
}
|
||||
const userId = loginInfo.user._id;
|
||||
const query = { _id: userId };
|
||||
const data = {
|
||||
$unset: {
|
||||
'services.accounts-lockout.unlockTime': 0,
|
||||
'services.accounts-lockout.failedAttempts': 0,
|
||||
},
|
||||
};
|
||||
Meteor.users.update(query, data);
|
||||
}
|
||||
|
||||
static incorrectPassword(
|
||||
failedAttempts,
|
||||
maxAttemptsAllowed,
|
||||
attemptsRemaining,
|
||||
) {
|
||||
throw new Meteor.Error(
|
||||
403,
|
||||
'Incorrect password',
|
||||
JSON.stringify({
|
||||
message: 'Incorrect password',
|
||||
failedAttempts,
|
||||
maxAttemptsAllowed,
|
||||
attemptsRemaining,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static tooManyAttempts(duration) {
|
||||
throw new Meteor.Error(
|
||||
403,
|
||||
'Too many attempts',
|
||||
JSON.stringify({
|
||||
message: 'Wrong passwords were submitted too many times. Account is locked for a while.',
|
||||
duration,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static knownUsers() {
|
||||
let knownUsers;
|
||||
try {
|
||||
knownUsers = Meteor.settings['accounts-lockout'].knownUsers;
|
||||
} catch (e) {
|
||||
knownUsers = false;
|
||||
}
|
||||
return knownUsers || false;
|
||||
}
|
||||
|
||||
static unlockTime(user) {
|
||||
let unlockTime;
|
||||
try {
|
||||
unlockTime = user.services['accounts-lockout'].unlockTime;
|
||||
} catch (e) {
|
||||
unlockTime = 0;
|
||||
}
|
||||
return unlockTime || 0;
|
||||
}
|
||||
|
||||
static failedAttempts(user) {
|
||||
let failedAttempts;
|
||||
try {
|
||||
failedAttempts = user.services['accounts-lockout'].failedAttempts;
|
||||
} catch (e) {
|
||||
failedAttempts = 0;
|
||||
}
|
||||
return failedAttempts || 0;
|
||||
}
|
||||
|
||||
static lastFailedAttempt(user) {
|
||||
let lastFailedAttempt;
|
||||
try {
|
||||
lastFailedAttempt = user.services['accounts-lockout'].lastFailedAttempt;
|
||||
} catch (e) {
|
||||
lastFailedAttempt = 0;
|
||||
}
|
||||
return lastFailedAttempt || 0;
|
||||
}
|
||||
|
||||
static firstFailedAttempt(user) {
|
||||
let firstFailedAttempt;
|
||||
try {
|
||||
firstFailedAttempt = user.services['accounts-lockout'].firstFailedAttempt;
|
||||
} catch (e) {
|
||||
firstFailedAttempt = 0;
|
||||
}
|
||||
return firstFailedAttempt || 0;
|
||||
}
|
||||
|
||||
static unlockAccount(userId) {
|
||||
const query = { _id: userId };
|
||||
const data = {
|
||||
$unset: {
|
||||
'services.accounts-lockout.unlockTime': 0,
|
||||
'services.accounts-lockout.failedAttempts': 0,
|
||||
},
|
||||
};
|
||||
Meteor.users.update(query, data);
|
||||
}
|
||||
}
|
||||
|
||||
export default KnownUser;
|
329
packages/wekan-accounts-lockout/src/unknownUser.js
Normal file
329
packages/wekan-accounts-lockout/src/unknownUser.js
Normal file
|
@ -0,0 +1,329 @@
|
|||
import { Meteor } from 'meteor/meteor';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import _AccountsLockoutCollection from './accountsLockoutCollection';
|
||||
|
||||
class UnknownUser {
|
||||
constructor(
|
||||
settings,
|
||||
{
|
||||
AccountsLockoutCollection = _AccountsLockoutCollection,
|
||||
} = {},
|
||||
) {
|
||||
this.AccountsLockoutCollection = AccountsLockoutCollection;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
startup() {
|
||||
if (!(this.settings instanceof Function)) {
|
||||
this.updateSettings();
|
||||
}
|
||||
this.scheduleUnlocksForLockedAccounts();
|
||||
this.unlockAccountsIfLockoutAlreadyExpired();
|
||||
this.hookIntoAccounts();
|
||||
}
|
||||
|
||||
updateSettings() {
|
||||
const settings = UnknownUser.unknownUsers();
|
||||
if (settings) {
|
||||
settings.forEach(function updateSetting({ key, value }) {
|
||||
this.settings[key] = value;
|
||||
});
|
||||
}
|
||||
this.validateSettings();
|
||||
}
|
||||
|
||||
validateSettings() {
|
||||
if (
|
||||
!this.settings.failuresBeforeLockout ||
|
||||
this.settings.failuresBeforeLockout < 0
|
||||
) {
|
||||
throw new Error('"failuresBeforeLockout" is not positive integer');
|
||||
}
|
||||
if (
|
||||
!this.settings.lockoutPeriod ||
|
||||
this.settings.lockoutPeriod < 0
|
||||
) {
|
||||
throw new Error('"lockoutPeriod" is not positive integer');
|
||||
}
|
||||
if (
|
||||
!this.settings.failureWindow ||
|
||||
this.settings.failureWindow < 0
|
||||
) {
|
||||
throw new Error('"failureWindow" is not positive integer');
|
||||
}
|
||||
}
|
||||
|
||||
scheduleUnlocksForLockedAccounts() {
|
||||
const lockedAccountsCursor = this.AccountsLockoutCollection.find(
|
||||
{
|
||||
'services.accounts-lockout.unlockTime': {
|
||||
$gt: Number(new Date()),
|
||||
},
|
||||
},
|
||||
{
|
||||
fields: {
|
||||
'services.accounts-lockout.unlockTime': 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
const currentTime = Number(new Date());
|
||||
lockedAccountsCursor.forEach((connection) => {
|
||||
let lockDuration = this.unlockTime(connection) - currentTime;
|
||||
if (lockDuration >= this.settings.lockoutPeriod) {
|
||||
lockDuration = this.settings.lockoutPeriod * 1000;
|
||||
}
|
||||
if (lockDuration <= 1) {
|
||||
lockDuration = 1;
|
||||
}
|
||||
Meteor.setTimeout(
|
||||
this.unlockAccount.bind(this, connection.clientAddress),
|
||||
lockDuration,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
unlockAccountsIfLockoutAlreadyExpired() {
|
||||
const currentTime = Number(new Date());
|
||||
const query = {
|
||||
'services.accounts-lockout.unlockTime': {
|
||||
$lt: currentTime,
|
||||
},
|
||||
};
|
||||
const data = {
|
||||
$unset: {
|
||||
'services.accounts-lockout.unlockTime': 0,
|
||||
'services.accounts-lockout.failedAttempts': 0,
|
||||
},
|
||||
};
|
||||
this.AccountsLockoutCollection.update(query, data);
|
||||
}
|
||||
|
||||
hookIntoAccounts() {
|
||||
Accounts.validateLoginAttempt(this.validateLoginAttempt.bind(this));
|
||||
Accounts.onLogin(this.onLogin.bind(this));
|
||||
}
|
||||
|
||||
validateLoginAttempt(loginInfo) {
|
||||
// don't interrupt non-password logins
|
||||
if (
|
||||
loginInfo.type !== 'password' ||
|
||||
loginInfo.user !== undefined ||
|
||||
loginInfo.error === undefined ||
|
||||
loginInfo.error.reason !== 'User not found'
|
||||
) {
|
||||
return loginInfo.allowed;
|
||||
}
|
||||
|
||||
if (this.settings instanceof Function) {
|
||||
this.settings = this.settings(loginInfo.connection);
|
||||
this.validateSettings();
|
||||
}
|
||||
|
||||
const clientAddress = loginInfo.connection.clientAddress;
|
||||
const unlockTime = this.unlockTime(loginInfo.connection);
|
||||
let failedAttempts = 1 + this.failedAttempts(loginInfo.connection);
|
||||
const firstFailedAttempt = this.firstFailedAttempt(loginInfo.connection);
|
||||
const currentTime = Number(new Date());
|
||||
|
||||
const canReset = (currentTime - firstFailedAttempt) > (1000 * this.settings.failureWindow);
|
||||
if (canReset) {
|
||||
failedAttempts = 1;
|
||||
this.resetAttempts(failedAttempts, clientAddress);
|
||||
}
|
||||
|
||||
const canIncrement = failedAttempts < this.settings.failuresBeforeLockout;
|
||||
if (canIncrement) {
|
||||
this.incrementAttempts(failedAttempts, clientAddress);
|
||||
}
|
||||
|
||||
const maxAttemptsAllowed = this.settings.failuresBeforeLockout;
|
||||
const attemptsRemaining = maxAttemptsAllowed - failedAttempts;
|
||||
if (unlockTime > currentTime) {
|
||||
let duration = unlockTime - currentTime;
|
||||
duration = Math.ceil(duration / 1000);
|
||||
duration = duration > 1 ? duration : 1;
|
||||
UnknownUser.tooManyAttempts(duration);
|
||||
}
|
||||
if (failedAttempts === maxAttemptsAllowed) {
|
||||
this.setNewUnlockTime(failedAttempts, clientAddress);
|
||||
|
||||
let duration = this.settings.lockoutPeriod;
|
||||
duration = Math.ceil(duration);
|
||||
duration = duration > 1 ? duration : 1;
|
||||
return UnknownUser.tooManyAttempts(duration);
|
||||
}
|
||||
return UnknownUser.userNotFound(
|
||||
failedAttempts,
|
||||
maxAttemptsAllowed,
|
||||
attemptsRemaining,
|
||||
);
|
||||
}
|
||||
|
||||
resetAttempts(
|
||||
failedAttempts,
|
||||
clientAddress,
|
||||
) {
|
||||
const currentTime = Number(new Date());
|
||||
const query = { clientAddress };
|
||||
const data = {
|
||||
$set: {
|
||||
'services.accounts-lockout.failedAttempts': failedAttempts,
|
||||
'services.accounts-lockout.lastFailedAttempt': currentTime,
|
||||
'services.accounts-lockout.firstFailedAttempt': currentTime,
|
||||
},
|
||||
};
|
||||
this.AccountsLockoutCollection.upsert(query, data);
|
||||
}
|
||||
|
||||
incrementAttempts(
|
||||
failedAttempts,
|
||||
clientAddress,
|
||||
) {
|
||||
const currentTime = Number(new Date());
|
||||
const query = { clientAddress };
|
||||
const data = {
|
||||
$set: {
|
||||
'services.accounts-lockout.failedAttempts': failedAttempts,
|
||||
'services.accounts-lockout.lastFailedAttempt': currentTime,
|
||||
},
|
||||
};
|
||||
this.AccountsLockoutCollection.upsert(query, data);
|
||||
}
|
||||
|
||||
setNewUnlockTime(
|
||||
failedAttempts,
|
||||
clientAddress,
|
||||
) {
|
||||
const currentTime = Number(new Date());
|
||||
const newUnlockTime = (1000 * this.settings.lockoutPeriod) + currentTime;
|
||||
const query = { clientAddress };
|
||||
const data = {
|
||||
$set: {
|
||||
'services.accounts-lockout.failedAttempts': failedAttempts,
|
||||
'services.accounts-lockout.lastFailedAttempt': currentTime,
|
||||
'services.accounts-lockout.unlockTime': newUnlockTime,
|
||||
},
|
||||
};
|
||||
this.AccountsLockoutCollection.upsert(query, data);
|
||||
Meteor.setTimeout(
|
||||
this.unlockAccount.bind(this, clientAddress),
|
||||
this.settings.lockoutPeriod * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
onLogin(loginInfo) {
|
||||
if (loginInfo.type !== 'password') {
|
||||
return;
|
||||
}
|
||||
const clientAddress = loginInfo.connection.clientAddress;
|
||||
const query = { clientAddress };
|
||||
const data = {
|
||||
$unset: {
|
||||
'services.accounts-lockout.unlockTime': 0,
|
||||
'services.accounts-lockout.failedAttempts': 0,
|
||||
},
|
||||
};
|
||||
this.AccountsLockoutCollection.update(query, data);
|
||||
}
|
||||
|
||||
static userNotFound(
|
||||
failedAttempts,
|
||||
maxAttemptsAllowed,
|
||||
attemptsRemaining,
|
||||
) {
|
||||
throw new Meteor.Error(
|
||||
403,
|
||||
'User not found',
|
||||
JSON.stringify({
|
||||
message: 'User not found',
|
||||
failedAttempts,
|
||||
maxAttemptsAllowed,
|
||||
attemptsRemaining,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static tooManyAttempts(duration) {
|
||||
throw new Meteor.Error(
|
||||
403,
|
||||
'Too many attempts',
|
||||
JSON.stringify({
|
||||
message: 'Wrong emails were submitted too many times. Account is locked for a while.',
|
||||
duration,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static unknownUsers() {
|
||||
let unknownUsers;
|
||||
try {
|
||||
unknownUsers = Meteor.settings['accounts-lockout'].unknownUsers;
|
||||
} catch (e) {
|
||||
unknownUsers = false;
|
||||
}
|
||||
return unknownUsers || false;
|
||||
}
|
||||
|
||||
findOneByConnection(connection) {
|
||||
return this.AccountsLockoutCollection.findOne({
|
||||
clientAddress: connection.clientAddress,
|
||||
});
|
||||
}
|
||||
|
||||
unlockTime(connection) {
|
||||
connection = this.findOneByConnection(connection);
|
||||
let unlockTime;
|
||||
try {
|
||||
unlockTime = connection.services['accounts-lockout'].unlockTime;
|
||||
} catch (e) {
|
||||
unlockTime = 0;
|
||||
}
|
||||
return unlockTime || 0;
|
||||
}
|
||||
|
||||
failedAttempts(connection) {
|
||||
connection = this.findOneByConnection(connection);
|
||||
let failedAttempts;
|
||||
try {
|
||||
failedAttempts = connection.services['accounts-lockout'].failedAttempts;
|
||||
} catch (e) {
|
||||
failedAttempts = 0;
|
||||
}
|
||||
return failedAttempts || 0;
|
||||
}
|
||||
|
||||
lastFailedAttempt(connection) {
|
||||
connection = this.findOneByConnection(connection);
|
||||
let lastFailedAttempt;
|
||||
try {
|
||||
lastFailedAttempt = connection.services['accounts-lockout'].lastFailedAttempt;
|
||||
} catch (e) {
|
||||
lastFailedAttempt = 0;
|
||||
}
|
||||
return lastFailedAttempt || 0;
|
||||
}
|
||||
|
||||
firstFailedAttempt(connection) {
|
||||
connection = this.findOneByConnection(connection);
|
||||
let firstFailedAttempt;
|
||||
try {
|
||||
firstFailedAttempt = connection.services['accounts-lockout'].firstFailedAttempt;
|
||||
} catch (e) {
|
||||
firstFailedAttempt = 0;
|
||||
}
|
||||
return firstFailedAttempt || 0;
|
||||
}
|
||||
|
||||
unlockAccount(clientAddress) {
|
||||
const query = { clientAddress };
|
||||
const data = {
|
||||
$unset: {
|
||||
'services.accounts-lockout.unlockTime': 0,
|
||||
'services.accounts-lockout.failedAttempts': 0,
|
||||
},
|
||||
};
|
||||
this.AccountsLockoutCollection.update(query, data);
|
||||
}
|
||||
}
|
||||
|
||||
export default UnknownUser;
|
1
packages/wekan-accounts-oidc/.gitignore
vendored
Normal file
1
packages/wekan-accounts-oidc/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
.versions
|
14
packages/wekan-accounts-oidc/LICENSE.txt
Normal file
14
packages/wekan-accounts-oidc/LICENSE.txt
Normal file
|
@ -0,0 +1,14 @@
|
|||
Copyright (C) 2016 SWITCH
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
75
packages/wekan-accounts-oidc/README.md
Normal file
75
packages/wekan-accounts-oidc/README.md
Normal file
|
@ -0,0 +1,75 @@
|
|||
# salleman:accounts-oidc package
|
||||
|
||||
A Meteor login service for OpenID Connect (OIDC).
|
||||
|
||||
## Installation
|
||||
|
||||
meteor add salleman:accounts-oidc
|
||||
|
||||
## Usage
|
||||
|
||||
`Meteor.loginWithOidc(options, callback)`
|
||||
* `options` - object containing options, see below (optional)
|
||||
* `callback` - callback function (optional)
|
||||
|
||||
#### Example
|
||||
|
||||
```js
|
||||
Template.myTemplateName.events({
|
||||
'click #login-button': function() {
|
||||
Meteor.loginWithOidc();
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
|
||||
## Options
|
||||
|
||||
These options override service configuration stored in the database.
|
||||
|
||||
* `loginStyle`: `redirect` or `popup`
|
||||
* `redirectUrl`: Where to redirect after successful login. Only used if `loginStyle` is set to `redirect`
|
||||
|
||||
## Manual Configuration Setup
|
||||
|
||||
You can manually configure this package by upserting the service configuration on startup. First, add the `service-configuration` package:
|
||||
|
||||
meteor add service-configuration
|
||||
|
||||
### Service Configuration
|
||||
|
||||
The following service configuration are available:
|
||||
|
||||
* `clientId`: OIDC client identifier
|
||||
* `secret`: OIDC client shared secret
|
||||
* `serverUrl`: URL of the OIDC server. e.g. `https://openid.example.org:8443`
|
||||
* `authorizationEndpoint`: Endpoint of the OIDC authorization service, e.g. `/oidc/authorize`
|
||||
* `tokenEndpoint`: Endpoint of the OIDC token service, e.g. `/oidc/token`
|
||||
* `userinfoEndpoint`: Endpoint of the OIDC userinfo service, e.g. `/oidc/userinfo`
|
||||
* `idTokenWhitelistFields`: A list of fields from IDToken to be added to Meteor.user().services.oidc object
|
||||
|
||||
### Project Configuration
|
||||
|
||||
Then in your project:
|
||||
|
||||
```js
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(function () {
|
||||
ServiceConfiguration.configurations.upsert(
|
||||
{ service: 'oidc' },
|
||||
{
|
||||
$set: {
|
||||
loginStyle: 'redirect',
|
||||
clientId: 'my-client-id-registered-with-the-oidc-server',
|
||||
secret: 'my-client-shared-secret',
|
||||
serverUrl: 'https://openid.example.org',
|
||||
authorizationEndpoint: '/oidc/authorize',
|
||||
tokenEndpoint: '/oidc/token',
|
||||
userinfoEndpoint: '/oidc/userinfo',
|
||||
idTokenWhitelistFields: []
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
22
packages/wekan-accounts-oidc/oidc.js
Normal file
22
packages/wekan-accounts-oidc/oidc.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
Accounts.oauth.registerService('oidc');
|
||||
|
||||
if (Meteor.isClient) {
|
||||
Meteor.loginWithOidc = function(options, callback) {
|
||||
// support a callback without options
|
||||
if (! callback && typeof options === "function") {
|
||||
callback = options;
|
||||
options = null;
|
||||
}
|
||||
|
||||
var credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback);
|
||||
Oidc.requestCredential(options, credentialRequestCompleteCallback);
|
||||
};
|
||||
} else {
|
||||
Accounts.addAutopublishFields({
|
||||
// not sure whether the OIDC api can be used from the browser,
|
||||
// thus not sure if we should be sending access tokens; but we do it
|
||||
// for all other oauth2 providers, and it may come in handy.
|
||||
forLoggedInUser: ['services.oidc'],
|
||||
forOtherUsers: ['services.oidc.id']
|
||||
});
|
||||
}
|
3
packages/wekan-accounts-oidc/oidc_login_button.css
Normal file
3
packages/wekan-accounts-oidc/oidc_login_button.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
#login-buttons-image-oidc {
|
||||
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACQUlEQVQ4T5WTy2taQRTGv4kSXw0IoYIihCjFmhhfhUqW9a+o0I2LInTRRbtw05V2I9KQuuimi24KXQqChIhQQcQgGGNz0YpvMCG1yL1tGqvBZsKMIIXcjQcOcznnfL+ZOecOEUVx4/Ly8mQ6neqxhKlUKmltbc1Nut2uqJ/bEnJAkiTmEhEEgVqtViiVyjuAP70j/Pj6Htbglzu52WyGdrsNUq1Wqc1mk939+9sHPP7wTVM232g0QMrlMrXb7bIFndgcbAk3ZPP1eh2kVCrRra2tRcFoNEK1WoXf78fg3Rxsfl3H3t4e3G43dnd3wWrMZjNqtRpIsVhcAFKpFPL5PBfF43H8TDj49/2XAvb393F2dgaNRgNKKaLR6ByQz+epw+HAwcEBisUijEYjgsEg1Go1pA9ODtC/+MZFDCKKIo9FIhEIggCSy+Xozs4OYrEY2ChDoRAIIVww/ujhxdrnFTSbTX6Cfr+Pi4sLhMNhnJ6egmSzWepyuZBIJGAwGBAIBLiY2ezTI74qg2UoFIqFr6ys4OrqiveKHB4eckAmk8FgMMD29jZ8Ph8XKj4/5uu/ZyXZKXBAOp2mHo+H/0isD6zDOp0Om5ubsAuvcA+/8ffpkSygUqmApFIp6vV6+b2ZsNfrodVqYTgcwqXtwul04pfhiSzg+PgYJJlMUovFwhvIbHV1lTs70c3NDSaTCa6vr+8A2FvodDr8CmwuepPJtIDIbvdfkInPz89ZRCKFQmFjNBqdjMfjpZ6jVquV1tfX3bcYegI7CyIWlgAAAABJRU5ErkJggg==');
|
||||
}
|
19
packages/wekan-accounts-oidc/package.js
Normal file
19
packages/wekan-accounts-oidc/package.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
Package.describe({
|
||||
summary: "OpenID Connect (OIDC) for Meteor accounts",
|
||||
version: "1.0.10",
|
||||
name: "wekan-accounts-oidc",
|
||||
git: "https://github.com/wekan/meteor-accounts-oidc.git",
|
||||
|
||||
});
|
||||
|
||||
Package.onUse(function(api) {
|
||||
api.use('accounts-base', ['client', 'server']);
|
||||
// Export Accounts (etc) to packages using this one.
|
||||
api.imply('accounts-base', ['client', 'server']);
|
||||
api.use('accounts-oauth', ['client', 'server']);
|
||||
api.use('wekan-oidc', ['client', 'server']);
|
||||
|
||||
api.addFiles('oidc_login_button.css', 'client');
|
||||
|
||||
api.addFiles('oidc.js');
|
||||
});
|
21
packages/wekan-ldap/LICENSE
Normal file
21
packages/wekan-ldap/LICENSE
Normal file
|
@ -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.
|
130
packages/wekan-ldap/README.md
Normal file
130
packages/wekan-ldap/README.md
Normal file
|
@ -0,0 +1,130 @@
|
|||
# meteor-ldap
|
||||
|
||||
This packages is based on the RocketChat ldap login package
|
||||
|
||||
# settings definition
|
||||
|
||||
LDAP_Enable: Self explanatory
|
||||
|
||||
LDAP_Port: The port of the LDAP server
|
||||
|
||||
LDAP_Host: The host server for the LDAP server
|
||||
|
||||
LDAP_BaseDN: The base DN for the LDAP Tree
|
||||
|
||||
LDAP_Login_Fallback: Fallback on the default authentication method
|
||||
|
||||
LDAP_Reconnect: Reconnect to the server if the connection is lost
|
||||
|
||||
LDAP_Timeout: self explanatory
|
||||
|
||||
LDAP_Idle_Timeout: self explanatory
|
||||
|
||||
LDAP_Connect_Timeout: self explanatory
|
||||
|
||||
LDAP_Authentication: If the LDAP needs a user account to search
|
||||
|
||||
LDAP_Authentication_UserDN: The search user DN
|
||||
|
||||
LDAP_Authentication_Password: The password for the search user
|
||||
|
||||
LDAP_Internal_Log_Level: The logging level for the module
|
||||
|
||||
LDAP_Background_Sync: If the sync of the users should be done in the
|
||||
background
|
||||
|
||||
LDAP_Background_Sync_Interval: At which interval does the background task sync
|
||||
|
||||
LDAP_Encryption: If using LDAPS, set it to 'ssl', else it will use 'ldap://'
|
||||
|
||||
LDAP_CA_Cert: The certification for the LDAPS server
|
||||
|
||||
LDAP_Reject_Unauthorized: Reject Unauthorized Certificate
|
||||
|
||||
LDAP_User_Search_Filter:
|
||||
|
||||
LDAP_User_Search_Scope:
|
||||
|
||||
LDAP_User_Search_Field: Which field is used to find the user
|
||||
|
||||
LDAP_Search_Page_Size:
|
||||
|
||||
LDAP_Search_Size_Limit:
|
||||
|
||||
LDAP_Group_Filter_Enable: enable group filtering
|
||||
|
||||
LDAP_Group_Filter_ObjectClass: The object class for filtering
|
||||
|
||||
LDAP_Group_Filter_Group_Id_Attribute:
|
||||
|
||||
LDAP_Group_Filter_Group_Member_Attribute:
|
||||
|
||||
LDAP_Group_Filter_Group_Member_Format:
|
||||
|
||||
LDAP_Group_Filter_Group_Name:
|
||||
|
||||
LDAP_Unique_Identifier_Field: This field is sometimes class GUID ( Globally Unique Identifier)
|
||||
|
||||
UTF8_Names_Slugify: Convert the username to utf8
|
||||
|
||||
LDAP_Username_Field: Which field contains the ldap username
|
||||
|
||||
LDAP_Fullname_Field: Which field contains the ldap full name
|
||||
|
||||
LDAP_Email_Match_Enable: Allow existing account matching by e-mail address when username does not match
|
||||
|
||||
LDAP_Email_Match_Require: Require existing account matching by e-mail address when username does match
|
||||
|
||||
LDAP_Email_Match_Verified: Require existing account email address to be verified for matching
|
||||
|
||||
LDAP_Email_Field: Which field contains the LDAP e-mail address
|
||||
|
||||
LDAP_Sync_User_Data:
|
||||
|
||||
LDAP_Sync_User_Data_FieldMap:
|
||||
|
||||
Accounts_CustomFields:
|
||||
|
||||
LDAP_Default_Domain: The default domain of the ldap it is used to create email if the field is not map correctly with the LDAP_Sync_User_Data_FieldMap
|
||||
|
||||
|
||||
|
||||
|
||||
# example settings.json
|
||||
```
|
||||
{
|
||||
"LDAP_Port": 389,
|
||||
"LDAP_Host": "localhost",
|
||||
"LDAP_BaseDN": "ou=user,dc=example,dc=org",
|
||||
"LDAP_Login_Fallback": false,
|
||||
"LDAP_Reconnect": true,
|
||||
"LDAP_Timeout": 10000,
|
||||
"LDAP_Idle_Timeout": 10000,
|
||||
"LDAP_Connect_Timeout": 10000,
|
||||
"LDAP_Authentication": true,
|
||||
"LDAP_Authentication_UserDN": "cn=admin,dc=example,dc=org",
|
||||
"LDAP_Authentication_Password": "admin",
|
||||
"LDAP_Internal_Log_Level": "debug",
|
||||
"LDAP_Background_Sync": false,
|
||||
"LDAP_Background_Sync_Interval": "100",
|
||||
"LDAP_Encryption": false,
|
||||
"LDAP_Reject_Unauthorized": false,
|
||||
"LDAP_Group_Filter_Enable": false,
|
||||
"LDAP_Search_Page_Size": 0,
|
||||
"LDAP_Search_Size_Limit": 0,
|
||||
"LDAP_User_Search_Filter": "",
|
||||
"LDAP_User_Search_Field": "uid",
|
||||
"LDAP_User_Search_Scope": "",
|
||||
"LDAP_Unique_Identifier_Field": "guid",
|
||||
"LDAP_Username_Field": "uid",
|
||||
"LDAP_Fullname_Field": "cn",
|
||||
"LDAP_Email_Match_Enable": true,
|
||||
"LDAP_Email_Match_Require": false,
|
||||
"LDAP_Email_Match_Verified": false,
|
||||
"LDAP_Email_Field": "mail",
|
||||
"LDAP_Sync_User_Data": false,
|
||||
"LDAP_Sync_User_Data_FieldMap": "{\"cn\":\"name\", \"mail\":\"email\"}",
|
||||
"LDAP_Merge_Existing_Users": true,
|
||||
"UTF8_Names_Slugify": true
|
||||
}
|
||||
```
|
52
packages/wekan-ldap/client/loginHelper.js
Normal file
52
packages/wekan-ldap/client/loginHelper.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
// Pass in username, password as normal
|
||||
// customLdapOptions should be passed in if you want to override LDAP_DEFAULTS
|
||||
// on any particular call (if you have multiple ldap servers you'd like to connect to)
|
||||
// You'll likely want to set the dn value here {dn: "..."}
|
||||
Meteor.loginWithLDAP = function(username, password, customLdapOptions, callback) {
|
||||
// Retrieve arguments as array
|
||||
const args = [];
|
||||
for (let i = 0; i < arguments.length; i++) {
|
||||
args.push(arguments[i]);
|
||||
}
|
||||
// Pull username and password
|
||||
username = args.shift();
|
||||
password = args.shift();
|
||||
|
||||
// Check if last argument is a function
|
||||
// if it is, pop it off and set callback to it
|
||||
if (typeof args[args.length-1] === 'function') {
|
||||
callback = args.pop();
|
||||
} else {
|
||||
callback = null;
|
||||
}
|
||||
|
||||
// if args still holds options item, grab it
|
||||
if (args.length > 0) {
|
||||
customLdapOptions = args.shift();
|
||||
} else {
|
||||
customLdapOptions = {};
|
||||
}
|
||||
|
||||
// Set up loginRequest object
|
||||
const loginRequest = {
|
||||
ldap: true,
|
||||
username,
|
||||
ldapPass: password,
|
||||
ldapOptions: customLdapOptions,
|
||||
};
|
||||
|
||||
Accounts.callLoginMethod({
|
||||
// Call login method with ldap = true
|
||||
// This will hook into our login handler for ldap
|
||||
methodArguments: [loginRequest],
|
||||
userCallback(error/*, result*/) {
|
||||
if (error) {
|
||||
if (callback) {
|
||||
callback(error);
|
||||
}
|
||||
} else if (callback) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
28
packages/wekan-ldap/package.js
Normal file
28
packages/wekan-ldap/package.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
Package.describe({
|
||||
name: 'wekan-ldap',
|
||||
version: '0.0.2',
|
||||
// Brief, one-line summary of the package.
|
||||
summary: 'Basic meteor login with ldap',
|
||||
// URL to the Git repository containing the source code for this package.
|
||||
git: 'https://github.com/wekan/wekan-ldap',
|
||||
// By default, Meteor will default to using README.md for documentation.
|
||||
// To avoid submitting documentation, set this field to null.
|
||||
documentation: 'README.md'
|
||||
});
|
||||
|
||||
|
||||
Package.onUse(function(api) {
|
||||
api.versionsFrom('2.7');
|
||||
api.use('yasaricli:slugify');
|
||||
api.use('ecmascript');
|
||||
api.use('underscore');
|
||||
api.use('sha');
|
||||
api.use('templating', 'client');
|
||||
|
||||
api.use('accounts-base', 'server');
|
||||
api.use('accounts-password', 'server');
|
||||
api.use('percolate:synced-cron', 'server');
|
||||
api.addFiles('client/loginHelper.js', 'client');
|
||||
|
||||
api.mainModule('server/index.js', 'server');
|
||||
});
|
1
packages/wekan-ldap/server/index.js
Normal file
1
packages/wekan-ldap/server/index.js
Normal file
|
@ -0,0 +1 @@
|
|||
import './loginHandler';
|
593
packages/wekan-ldap/server/ldap.js
Normal file
593
packages/wekan-ldap/server/ldap.js
Normal file
|
@ -0,0 +1,593 @@
|
|||
import ldapjs from 'ldapjs';
|
||||
import util from 'util';
|
||||
import Bunyan from 'bunyan';
|
||||
import {log_debug, log_info, log_warn, log_error} from './logger';
|
||||
|
||||
|
||||
export default class LDAP {
|
||||
constructor() {
|
||||
this.ldapjs = ldapjs;
|
||||
|
||||
this.connected = false;
|
||||
|
||||
this.options = {
|
||||
host : this.constructor.settings_get('LDAP_HOST'),
|
||||
port : this.constructor.settings_get('LDAP_PORT'),
|
||||
Reconnect : this.constructor.settings_get('LDAP_RECONNECT'),
|
||||
timeout : this.constructor.settings_get('LDAP_TIMEOUT'),
|
||||
connect_timeout : this.constructor.settings_get('LDAP_CONNECT_TIMEOUT'),
|
||||
idle_timeout : this.constructor.settings_get('LDAP_IDLE_TIMEOUT'),
|
||||
encryption : this.constructor.settings_get('LDAP_ENCRYPTION'),
|
||||
ca_cert : this.constructor.settings_get('LDAP_CA_CERT'),
|
||||
reject_unauthorized : this.constructor.settings_get('LDAP_REJECT_UNAUTHORIZED') !== undefined ? this.constructor.settings_get('LDAP_REJECT_UNAUTHORIZED') : true,
|
||||
Authentication : this.constructor.settings_get('LDAP_AUTHENTIFICATION'),
|
||||
Authentication_UserDN : this.constructor.settings_get('LDAP_AUTHENTIFICATION_USERDN'),
|
||||
Authentication_Password : this.constructor.settings_get('LDAP_AUTHENTIFICATION_PASSWORD'),
|
||||
Authentication_Fallback : this.constructor.settings_get('LDAP_LOGIN_FALLBACK'),
|
||||
BaseDN : this.constructor.settings_get('LDAP_BASEDN'),
|
||||
Internal_Log_Level : this.constructor.settings_get('INTERNAL_LOG_LEVEL'),
|
||||
User_Authentication : this.constructor.settings_get('LDAP_USER_AUTHENTICATION'),
|
||||
User_Authentication_Field : this.constructor.settings_get('LDAP_USER_AUTHENTICATION_FIELD'),
|
||||
User_Attributes : this.constructor.settings_get('LDAP_USER_ATTRIBUTES'),
|
||||
User_Search_Filter : this.constructor.settings_get('LDAP_USER_SEARCH_FILTER'),
|
||||
User_Search_Scope : this.constructor.settings_get('LDAP_USER_SEARCH_SCOPE'),
|
||||
User_Search_Field : this.constructor.settings_get('LDAP_USER_SEARCH_FIELD'),
|
||||
Search_Page_Size : this.constructor.settings_get('LDAP_SEARCH_PAGE_SIZE'),
|
||||
Search_Size_Limit : this.constructor.settings_get('LDAP_SEARCH_SIZE_LIMIT'),
|
||||
group_filter_enabled : this.constructor.settings_get('LDAP_GROUP_FILTER_ENABLE'),
|
||||
group_filter_object_class : this.constructor.settings_get('LDAP_GROUP_FILTER_OBJECTCLASS'),
|
||||
group_filter_group_id_attribute : this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_ID_ATTRIBUTE'),
|
||||
group_filter_group_member_attribute: this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE'),
|
||||
group_filter_group_member_format : this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_MEMBER_FORMAT'),
|
||||
group_filter_group_name : this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_NAME'),
|
||||
AD_Simple_Auth : this.constructor.settings_get('LDAP_AD_SIMPLE_AUTH'),
|
||||
Default_Domain : this.constructor.settings_get('LDAP_DEFAULT_DOMAIN'),
|
||||
};
|
||||
}
|
||||
|
||||
static settings_get(name, ...args) {
|
||||
let value = process.env[name];
|
||||
if (value !== undefined) {
|
||||
if (value === 'true' || value === 'false') {
|
||||
value = JSON.parse(value);
|
||||
} else if (value !== '' && !isNaN(value)) {
|
||||
value = Number(value);
|
||||
}
|
||||
return value;
|
||||
} else {
|
||||
log_warn(`Lookup for unset variable: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
connectSync(...args) {
|
||||
if (!this._connectSync) {
|
||||
this._connectSync = Meteor.wrapAsync(this.connectAsync, this);
|
||||
}
|
||||
return this._connectSync(...args);
|
||||
}
|
||||
|
||||
searchAllSync(...args) {
|
||||
|
||||
if (!this._searchAllSync) {
|
||||
this._searchAllSync = Meteor.wrapAsync(this.searchAllAsync, this);
|
||||
}
|
||||
return this._searchAllSync(...args);
|
||||
}
|
||||
|
||||
connectAsync(callback) {
|
||||
log_info('Init setup');
|
||||
|
||||
let replied = false;
|
||||
|
||||
const connectionOptions = {
|
||||
url : `${this.options.host}:${this.options.port}`,
|
||||
timeout : this.options.timeout,
|
||||
connectTimeout: this.options.connect_timeout,
|
||||
idleTimeout : this.options.idle_timeout,
|
||||
reconnect : this.options.Reconnect,
|
||||
};
|
||||
|
||||
if (this.options.Internal_Log_Level !== 'disabled') {
|
||||
connectionOptions.log = new Bunyan({
|
||||
name : 'ldapjs',
|
||||
component: 'client',
|
||||
stream : process.stderr,
|
||||
level : this.options.Internal_Log_Level,
|
||||
});
|
||||
}
|
||||
|
||||
const tlsOptions = {
|
||||
rejectUnauthorized: this.options.reject_unauthorized,
|
||||
};
|
||||
|
||||
if (this.options.ca_cert && this.options.ca_cert !== '') {
|
||||
// Split CA cert into array of strings
|
||||
const chainLines = this.constructor.settings_get('LDAP_CA_CERT').replace(/\\n/g,'\n').split('\n');
|
||||
let cert = [];
|
||||
const ca = [];
|
||||
chainLines.forEach((line) => {
|
||||
cert.push(line);
|
||||
if (line.match(/-END CERTIFICATE-/)) {
|
||||
ca.push(cert.join('\n'));
|
||||
cert = [];
|
||||
}
|
||||
});
|
||||
tlsOptions.ca = ca;
|
||||
}
|
||||
|
||||
if (this.options.encryption === 'ssl') {
|
||||
connectionOptions.url = `ldaps://${connectionOptions.url}`;
|
||||
connectionOptions.tlsOptions = tlsOptions;
|
||||
} else {
|
||||
connectionOptions.url = `ldap://${connectionOptions.url}`;
|
||||
}
|
||||
|
||||
log_info('Connecting', connectionOptions.url);
|
||||
log_debug(`connectionOptions${util.inspect(connectionOptions)}`);
|
||||
|
||||
this.client = ldapjs.createClient(connectionOptions);
|
||||
|
||||
this.bindSync = Meteor.wrapAsync(this.client.bind, this.client);
|
||||
|
||||
this.client.on('error', (error) => {
|
||||
log_error('connection', error);
|
||||
if (replied === false) {
|
||||
replied = true;
|
||||
callback(error, null);
|
||||
}
|
||||
});
|
||||
|
||||
this.client.on('idle', () => {
|
||||
log_info('Idle');
|
||||
this.disconnect();
|
||||
});
|
||||
|
||||
this.client.on('close', () => {
|
||||
log_info('Closed');
|
||||
});
|
||||
|
||||
if (this.options.encryption === 'tls') {
|
||||
// Set host parameter for tls.connect which is used by ldapjs starttls. This shouldn't be needed in newer nodejs versions (e.g v5.6.0).
|
||||
// https://github.com/RocketChat/Rocket.Chat/issues/2035
|
||||
// https://github.com/mcavage/node-ldapjs/issues/349
|
||||
tlsOptions.host = this.options.host;
|
||||
|
||||
log_info('Starting TLS');
|
||||
log_debug('tlsOptions', tlsOptions);
|
||||
|
||||
this.client.starttls(tlsOptions, null, (error, response) => {
|
||||
if (error) {
|
||||
log_error('TLS connection', error);
|
||||
if (replied === false) {
|
||||
replied = true;
|
||||
callback(error, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
log_info('TLS connected');
|
||||
this.connected = true;
|
||||
if (replied === false) {
|
||||
replied = true;
|
||||
callback(null, response);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.client.on('connect', (response) => {
|
||||
log_info('LDAP connected');
|
||||
this.connected = true;
|
||||
if (replied === false) {
|
||||
replied = true;
|
||||
callback(null, response);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (replied === false) {
|
||||
log_error('connection time out', connectionOptions.connectTimeout);
|
||||
replied = true;
|
||||
callback(new Error('Timeout'));
|
||||
}
|
||||
}, connectionOptions.connectTimeout);
|
||||
}
|
||||
|
||||
getUserFilter(username) {
|
||||
const filter = [];
|
||||
|
||||
if (this.options.User_Search_Filter !== '') {
|
||||
if (this.options.User_Search_Filter[0] === '(') {
|
||||
filter.push(`${this.options.User_Search_Filter}`);
|
||||
} else {
|
||||
filter.push(`(${this.options.User_Search_Filter})`);
|
||||
}
|
||||
}
|
||||
|
||||
const usernameFilter = this.options.User_Search_Field.split(',').map((item) => `(${item}=${username})`);
|
||||
|
||||
if (usernameFilter.length === 0) {
|
||||
log_error('LDAP_LDAP_User_Search_Field not defined');
|
||||
} else if (usernameFilter.length === 1) {
|
||||
filter.push(`${usernameFilter[0]}`);
|
||||
} else {
|
||||
filter.push(`(|${usernameFilter.join('')})`);
|
||||
}
|
||||
|
||||
return `(&${filter.join('')})`;
|
||||
}
|
||||
|
||||
bindUserIfNecessary(username, password) {
|
||||
|
||||
if (this.domainBinded === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.options.User_Authentication) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* if SimpleAuth is configured, the BaseDN is not needed */
|
||||
if (!this.options.BaseDN && !this.options.AD_Simple_Auth) throw new Error('BaseDN is not provided');
|
||||
|
||||
var userDn = "";
|
||||
if (this.options.AD_Simple_Auth === true || this.options.AD_Simple_Auth === 'true') {
|
||||
userDn = `${username}@${this.options.Default_Domain}`;
|
||||
} else {
|
||||
userDn = `${this.options.User_Authentication_Field}=${username},${this.options.BaseDN}`;
|
||||
}
|
||||
|
||||
log_info('Binding with User', userDn);
|
||||
|
||||
this.bindSync(userDn, password);
|
||||
this.domainBinded = true;
|
||||
}
|
||||
|
||||
bindIfNecessary() {
|
||||
if (this.domainBinded === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.options.Authentication !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
log_info('Binding UserDN', this.options.Authentication_UserDN);
|
||||
|
||||
this.bindSync(this.options.Authentication_UserDN, this.options.Authentication_Password);
|
||||
this.domainBinded = true;
|
||||
}
|
||||
|
||||
searchUsersSync(username, page) {
|
||||
this.bindIfNecessary();
|
||||
const searchOptions = {
|
||||
filter : this.getUserFilter(username),
|
||||
scope : this.options.User_Search_Scope || 'sub',
|
||||
sizeLimit: this.options.Search_Size_Limit,
|
||||
};
|
||||
|
||||
if (!!this.options.User_Attributes) searchOptions.attributes = this.options.User_Attributes.split(',');
|
||||
|
||||
if (this.options.Search_Page_Size > 0) {
|
||||
searchOptions.paged = {
|
||||
pageSize : this.options.Search_Page_Size,
|
||||
pagePause: !!page,
|
||||
};
|
||||
}
|
||||
|
||||
log_info('Searching user', username);
|
||||
log_debug('searchOptions', searchOptions);
|
||||
log_debug('BaseDN', this.options.BaseDN);
|
||||
|
||||
if (page) {
|
||||
return this.searchAllPaged(this.options.BaseDN, searchOptions, page);
|
||||
}
|
||||
|
||||
return this.searchAllSync(this.options.BaseDN, searchOptions);
|
||||
}
|
||||
|
||||
getUserByIdSync(id, attribute) {
|
||||
this.bindIfNecessary();
|
||||
|
||||
const Unique_Identifier_Field = this.constructor.settings_get('LDAP_UNIQUE_IDENTIFIER_FIELD').split(',');
|
||||
|
||||
let filter;
|
||||
|
||||
if (attribute) {
|
||||
filter = new this.ldapjs.filters.EqualityFilter({
|
||||
attribute,
|
||||
value: Buffer.from(id, 'hex'),
|
||||
});
|
||||
} else {
|
||||
const filters = [];
|
||||
Unique_Identifier_Field.forEach((item) => {
|
||||
filters.push(new this.ldapjs.filters.EqualityFilter({
|
||||
attribute: item,
|
||||
value : Buffer.from(id, 'hex'),
|
||||
}));
|
||||
});
|
||||
|
||||
filter = new this.ldapjs.filters.OrFilter({ filters });
|
||||
}
|
||||
|
||||
const searchOptions = {
|
||||
filter,
|
||||
scope: 'sub',
|
||||
};
|
||||
|
||||
log_info('Searching by id', id);
|
||||
log_debug('search filter', searchOptions.filter.toString());
|
||||
log_debug('BaseDN', this.options.BaseDN);
|
||||
|
||||
const result = this.searchAllSync(this.options.BaseDN, searchOptions);
|
||||
|
||||
if (!Array.isArray(result) || result.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.length > 1) {
|
||||
log_error('Search by id', id, 'returned', result.length, 'records');
|
||||
}
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
getUserByUsernameSync(username) {
|
||||
this.bindIfNecessary();
|
||||
|
||||
const searchOptions = {
|
||||
filter: this.getUserFilter(username),
|
||||
scope : this.options.User_Search_Scope || 'sub',
|
||||
};
|
||||
|
||||
log_info('Searching user', username);
|
||||
log_debug('searchOptions', searchOptions);
|
||||
log_debug('BaseDN', this.options.BaseDN);
|
||||
|
||||
const result = this.searchAllSync(this.options.BaseDN, searchOptions);
|
||||
|
||||
if (!Array.isArray(result) || result.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.length > 1) {
|
||||
log_error('Search by username', username, 'returned', result.length, 'records');
|
||||
}
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
getUserGroups(username, ldapUser) {
|
||||
if (!this.options.group_filter_enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const filter = ['(&'];
|
||||
|
||||
if (this.options.group_filter_object_class !== '') {
|
||||
filter.push(`(objectclass=${this.options.group_filter_object_class})`);
|
||||
}
|
||||
|
||||
if (this.options.group_filter_group_member_attribute !== '') {
|
||||
const format_value = ldapUser[this.options.group_filter_group_member_format];
|
||||
if (format_value) {
|
||||
filter.push(`(${this.options.group_filter_group_member_attribute}=${format_value})`);
|
||||
}
|
||||
}
|
||||
|
||||
filter.push(')');
|
||||
|
||||
const searchOptions = {
|
||||
filter: filter.join('').replace(/#{username}/g, username),
|
||||
scope : 'sub',
|
||||
};
|
||||
|
||||
log_debug('Group list filter LDAP:', searchOptions.filter);
|
||||
|
||||
const result = this.searchAllSync(this.options.BaseDN, searchOptions);
|
||||
|
||||
if (!Array.isArray(result) || result.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const grp_identifier = this.options.group_filter_group_id_attribute || 'cn';
|
||||
const groups = [];
|
||||
result.map((item) => {
|
||||
groups.push(item[grp_identifier]);
|
||||
});
|
||||
log_debug(`Groups: ${groups.join(', ')}`);
|
||||
return groups;
|
||||
|
||||
}
|
||||
|
||||
isUserInGroup(username, ldapUser) {
|
||||
if (!this.options.group_filter_enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const grps = this.getUserGroups(username, ldapUser);
|
||||
|
||||
const filter = ['(&'];
|
||||
|
||||
if (this.options.group_filter_object_class !== '') {
|
||||
filter.push(`(objectclass=${this.options.group_filter_object_class})`);
|
||||
}
|
||||
|
||||
if (this.options.group_filter_group_member_attribute !== '') {
|
||||
const format_value = ldapUser[this.options.group_filter_group_member_format];
|
||||
if (format_value) {
|
||||
filter.push(`(${this.options.group_filter_group_member_attribute}=${format_value})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.options.group_filter_group_id_attribute !== '') {
|
||||
filter.push(`(${this.options.group_filter_group_id_attribute}=${this.options.group_filter_group_name})`);
|
||||
}
|
||||
filter.push(')');
|
||||
|
||||
const searchOptions = {
|
||||
filter: filter.join('').replace(/#{username}/g, username),
|
||||
scope : 'sub',
|
||||
};
|
||||
|
||||
log_debug('Group filter LDAP:', searchOptions.filter);
|
||||
|
||||
const result = this.searchAllSync(this.options.BaseDN, searchOptions);
|
||||
|
||||
if (!Array.isArray(result) || result.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
extractLdapEntryData(entry) {
|
||||
const values = {
|
||||
_raw: entry.raw,
|
||||
};
|
||||
|
||||
Object.keys(values._raw).forEach((key) => {
|
||||
const value = values._raw[key];
|
||||
|
||||
if (!['thumbnailPhoto', 'jpegPhoto'].includes(key)) {
|
||||
if (value instanceof Buffer) {
|
||||
values[key] = value.toString();
|
||||
} else {
|
||||
values[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
searchAllPaged(BaseDN, options, page) {
|
||||
this.bindIfNecessary();
|
||||
|
||||
const processPage = ({ entries, title, end, next }) => {
|
||||
log_info(title);
|
||||
// Force LDAP idle to wait the record processing
|
||||
this.client._updateIdle(true);
|
||||
page(null, entries, {
|
||||
end, next: () => {
|
||||
// Reset idle timer
|
||||
this.client._updateIdle();
|
||||
next && next();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.client.search(BaseDN, options, (error, res) => {
|
||||
if (error) {
|
||||
log_error(error);
|
||||
page(error);
|
||||
return;
|
||||
}
|
||||
|
||||
res.on('error', (error) => {
|
||||
log_error(error);
|
||||
page(error);
|
||||
return;
|
||||
});
|
||||
|
||||
let entries = [];
|
||||
|
||||
const internalPageSize = options.paged && options.paged.pageSize > 0 ? options.paged.pageSize * 2 : 500;
|
||||
|
||||
res.on('searchEntry', (entry) => {
|
||||
entries.push(this.extractLdapEntryData(entry));
|
||||
|
||||
if (entries.length >= internalPageSize) {
|
||||
processPage({
|
||||
entries,
|
||||
title: 'Internal Page',
|
||||
end : false,
|
||||
});
|
||||
entries = [];
|
||||
}
|
||||
});
|
||||
|
||||
res.on('page', (result, next) => {
|
||||
if (!next) {
|
||||
this.client._updateIdle(true);
|
||||
processPage({
|
||||
entries,
|
||||
title: 'Final Page',
|
||||
end : true,
|
||||
});
|
||||
} else if (entries.length) {
|
||||
log_info('Page');
|
||||
processPage({
|
||||
entries,
|
||||
title: 'Page',
|
||||
end : false,
|
||||
next,
|
||||
});
|
||||
entries = [];
|
||||
}
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (entries.length) {
|
||||
processPage({
|
||||
entries,
|
||||
title: 'Final Page',
|
||||
end : true,
|
||||
});
|
||||
entries = [];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
searchAllAsync(BaseDN, options, callback) {
|
||||
this.bindIfNecessary();
|
||||
|
||||
this.client.search(BaseDN, options, (error, res) => {
|
||||
if (error) {
|
||||
log_error(error);
|
||||
callback(error);
|
||||
return;
|
||||
}
|
||||
|
||||
res.on('error', (error) => {
|
||||
log_error(error);
|
||||
callback(error);
|
||||
return;
|
||||
});
|
||||
|
||||
const entries = [];
|
||||
|
||||
res.on('searchEntry', (entry) => {
|
||||
entries.push(this.extractLdapEntryData(entry));
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
log_info('Search result count', entries.length);
|
||||
callback(null, entries);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
authSync(dn, password) {
|
||||
log_info('Authenticating', dn);
|
||||
|
||||
try {
|
||||
if (password === '') {
|
||||
throw new Error('Password is not provided');
|
||||
}
|
||||
this.bindSync(dn, password);
|
||||
log_info('Authenticated', dn);
|
||||
return true;
|
||||
} catch (error) {
|
||||
log_info('Not authenticated', dn);
|
||||
log_debug('error', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.connected = false;
|
||||
this.domainBinded = false;
|
||||
log_info('Disconecting');
|
||||
this.client.unbind();
|
||||
}
|
||||
}
|
15
packages/wekan-ldap/server/logger.js
Normal file
15
packages/wekan-ldap/server/logger.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
const isLogEnabled = (process.env.LDAP_LOG_ENABLED === 'true');
|
||||
|
||||
|
||||
function log (level, message, data) {
|
||||
if (isLogEnabled) {
|
||||
console.log(`[${level}] ${message} ${ data ? JSON.stringify(data, null, 2) : '' }`);
|
||||
}
|
||||
}
|
||||
|
||||
function log_debug (...args) { log('DEBUG', ...args); }
|
||||
function log_info (...args) { log('INFO', ...args); }
|
||||
function log_warn (...args) { log('WARN', ...args); }
|
||||
function log_error (...args) { log('ERROR', ...args); }
|
||||
|
||||
export { log, log_debug, log_info, log_warn, log_error };
|
252
packages/wekan-ldap/server/loginHandler.js
Normal file
252
packages/wekan-ldap/server/loginHandler.js
Normal file
|
@ -0,0 +1,252 @@
|
|||
import {slug, getLdapUsername, getLdapEmail, getLdapUserUniqueID, syncUserData, addLdapUser} from './sync';
|
||||
import LDAP from './ldap';
|
||||
import { log_debug, log_info, log_warn, log_error } from './logger';
|
||||
|
||||
function fallbackDefaultAccountSystem(bind, username, password) {
|
||||
if (typeof username === 'string') {
|
||||
if (username.indexOf('@') === -1) {
|
||||
username = {username};
|
||||
} else {
|
||||
username = {email: username};
|
||||
}
|
||||
}
|
||||
|
||||
log_info('Fallback to default account system: ', username );
|
||||
|
||||
const loginRequest = {
|
||||
user: username,
|
||||
password: {
|
||||
digest: SHA256(password),
|
||||
algorithm: 'sha-256',
|
||||
},
|
||||
};
|
||||
log_debug('Fallback options: ', loginRequest);
|
||||
|
||||
return Accounts._runLoginHandlers(bind, loginRequest);
|
||||
}
|
||||
|
||||
Accounts.registerLoginHandler('ldap', function(loginRequest) {
|
||||
if (!loginRequest.ldap || !loginRequest.ldapOptions) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
log_info('Init LDAP login', loginRequest.username);
|
||||
|
||||
if (LDAP.settings_get('LDAP_ENABLE') !== true) {
|
||||
return fallbackDefaultAccountSystem(this, loginRequest.username, loginRequest.ldapPass);
|
||||
}
|
||||
|
||||
const self = this;
|
||||
const ldap = new LDAP();
|
||||
let ldapUser;
|
||||
|
||||
try {
|
||||
|
||||
ldap.connectSync();
|
||||
|
||||
if (!!LDAP.settings_get('LDAP_USER_AUTHENTICATION')) {
|
||||
ldap.bindUserIfNecessary(loginRequest.username, loginRequest.ldapPass);
|
||||
ldapUser = ldap.searchUsersSync(loginRequest.username)[0];
|
||||
} else {
|
||||
|
||||
const users = ldap.searchUsersSync(loginRequest.username);
|
||||
|
||||
if (users.length !== 1) {
|
||||
log_info('Search returned', users.length, 'record(s) for', loginRequest.username);
|
||||
throw new Error('User not Found');
|
||||
}
|
||||
|
||||
if (ldap.isUserInGroup(loginRequest.username, users[0])) {
|
||||
ldapUser = users[0];
|
||||
} else {
|
||||
throw new Error('User not in a valid group');
|
||||
}
|
||||
|
||||
if (ldap.authSync(users[0].dn, loginRequest.ldapPass) !== true) {
|
||||
ldapUser = null;
|
||||
log_info('Wrong password for', loginRequest.username)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
}
|
||||
|
||||
if (!ldapUser) {
|
||||
if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') === true) {
|
||||
return fallbackDefaultAccountSystem(self, loginRequest.username, loginRequest.ldapPass);
|
||||
}
|
||||
|
||||
throw new Meteor.Error('LDAP-login-error', `LDAP Authentication failed with provided username [${ loginRequest.username }]`);
|
||||
}
|
||||
|
||||
// Look to see if user already exists
|
||||
|
||||
let userQuery;
|
||||
|
||||
const Unique_Identifier_Field = getLdapUserUniqueID(ldapUser);
|
||||
let user;
|
||||
// Attempt to find user by unique identifier
|
||||
|
||||
if (Unique_Identifier_Field) {
|
||||
userQuery = {
|
||||
'services.ldap.id': Unique_Identifier_Field.value,
|
||||
};
|
||||
|
||||
log_info('Querying user');
|
||||
log_debug('userQuery', userQuery);
|
||||
|
||||
user = Meteor.users.findOne(userQuery);
|
||||
}
|
||||
|
||||
// Attempt to find user by username
|
||||
|
||||
let username;
|
||||
let email;
|
||||
|
||||
if (LDAP.settings_get('LDAP_USERNAME_FIELD') !== '') {
|
||||
username = slug(getLdapUsername(ldapUser));
|
||||
} else {
|
||||
username = slug(loginRequest.username);
|
||||
}
|
||||
|
||||
if(LDAP.settings_get('LDAP_EMAIL_FIELD') !== '') {
|
||||
email = getLdapEmail(ldapUser);
|
||||
}
|
||||
|
||||
|
||||
if (!user) {
|
||||
if(email && LDAP.settings_get('LDAP_EMAIL_MATCH_REQUIRE') === true) {
|
||||
if(LDAP.settings_get('LDAP_EMAIL_MATCH_VERIFIED') === true) {
|
||||
userQuery = {
|
||||
'_id' : username,
|
||||
'emails.0.address' : email,
|
||||
'emails.0.verified' : true
|
||||
};
|
||||
} else {
|
||||
userQuery = {
|
||||
'_id' : username,
|
||||
'emails.0.address' : email
|
||||
};
|
||||
}
|
||||
} else {
|
||||
userQuery = {
|
||||
username
|
||||
};
|
||||
}
|
||||
|
||||
log_debug('userQuery', userQuery);
|
||||
|
||||
user = Meteor.users.findOne(userQuery);
|
||||
}
|
||||
|
||||
// Attempt to find user by e-mail address only
|
||||
|
||||
if (!user && email && LDAP.settings_get('LDAP_EMAIL_MATCH_ENABLE') === true) {
|
||||
|
||||
log_info('No user exists with username', username, '- attempting to find by e-mail address instead');
|
||||
|
||||
if(LDAP.settings_get('LDAP_EMAIL_MATCH_VERIFIED') === true) {
|
||||
userQuery = {
|
||||
'emails.0.address': email,
|
||||
'emails.0.verified' : true
|
||||
};
|
||||
} else {
|
||||
userQuery = {
|
||||
'emails.0.address' : email
|
||||
};
|
||||
}
|
||||
|
||||
log_debug('userQuery', userQuery);
|
||||
|
||||
user = Meteor.users.findOne(userQuery);
|
||||
|
||||
}
|
||||
|
||||
// Login user if they exist
|
||||
if (user) {
|
||||
if (user.authenticationMethod !== 'ldap' && LDAP.settings_get('LDAP_MERGE_EXISTING_USERS') !== true) {
|
||||
log_info('User exists without "authenticationMethod : ldap"');
|
||||
throw new Meteor.Error('LDAP-login-error', `LDAP Authentication succeded, but there's already a matching Wekan account in MongoDB`);
|
||||
}
|
||||
|
||||
log_info('Logging user');
|
||||
|
||||
const stampedToken = Accounts._generateStampedLoginToken();
|
||||
const update_data = {
|
||||
$push: {
|
||||
'services.resume.loginTokens': Accounts._hashStampedToken(stampedToken),
|
||||
},
|
||||
};
|
||||
|
||||
if (LDAP.settings_get('LDAP_SYNC_ADMIN_STATUS') === true) {
|
||||
log_debug('Updating admin status');
|
||||
const targetGroups = LDAP.settings_get('LDAP_SYNC_ADMIN_GROUPS').split(',');
|
||||
const groups = ldap.getUserGroups(username, ldapUser).filter((value) => targetGroups.includes(value));
|
||||
|
||||
user.isAdmin = groups.length > 0;
|
||||
Meteor.users.update({_id: user._id}, {$set: {isAdmin: user.isAdmin}});
|
||||
}
|
||||
|
||||
if( LDAP.settings_get('LDAP_SYNC_GROUP_ROLES') === true ) {
|
||||
log_debug('Updating Groups/Roles');
|
||||
const groups = ldap.getUserGroups(username, ldapUser);
|
||||
|
||||
if( groups.length > 0 ) {
|
||||
Roles.setUserRoles(user._id, groups );
|
||||
log_info(`Updated roles to:${ groups.join(',')}`);
|
||||
}
|
||||
}
|
||||
|
||||
Meteor.users.update(user._id, update_data );
|
||||
|
||||
syncUserData(user, ldapUser);
|
||||
|
||||
if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') === true) {
|
||||
Accounts.setPassword(user._id, loginRequest.ldapPass, {logout: false});
|
||||
}
|
||||
|
||||
return {
|
||||
userId: user._id,
|
||||
token: stampedToken.token,
|
||||
};
|
||||
}
|
||||
|
||||
// Create new user
|
||||
|
||||
log_info('User does not exist, creating', username);
|
||||
|
||||
if (LDAP.settings_get('LDAP_USERNAME_FIELD') === '') {
|
||||
username = undefined;
|
||||
}
|
||||
|
||||
if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') !== true) {
|
||||
loginRequest.ldapPass = undefined;
|
||||
}
|
||||
|
||||
const result = addLdapUser(ldapUser, username, loginRequest.ldapPass);
|
||||
|
||||
if (LDAP.settings_get('LDAP_SYNC_ADMIN_STATUS') === true) {
|
||||
log_debug('Updating admin status');
|
||||
const targetGroups = LDAP.settings_get('LDAP_SYNC_ADMIN_GROUPS').split(',');
|
||||
const groups = ldap.getUserGroups(username, ldapUser).filter((value) => targetGroups.includes(value));
|
||||
|
||||
result.isAdmin = groups.length > 0;
|
||||
Meteor.users.update({_id: result.userId}, {$set: {isAdmin: result.isAdmin}});
|
||||
}
|
||||
|
||||
if( LDAP.settings_get('LDAP_SYNC_GROUP_ROLES') === true ) {
|
||||
const groups = ldap.getUserGroups(username, ldapUser);
|
||||
if( groups.length > 0 ) {
|
||||
Roles.setUserRoles(result.userId, groups );
|
||||
log_info(`Set roles to:${ groups.join(',')}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (result instanceof Error) {
|
||||
throw result;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
474
packages/wekan-ldap/server/sync.js
Normal file
474
packages/wekan-ldap/server/sync.js
Normal file
|
@ -0,0 +1,474 @@
|
|||
import _ from 'underscore';
|
||||
import SyncedCron from 'meteor/percolate:synced-cron';
|
||||
import LDAP from './ldap';
|
||||
import { log_debug, log_info, log_warn, log_error } from './logger';
|
||||
|
||||
Object.defineProperty(Object.prototype, "getLDAPValue", {
|
||||
value: function (prop) {
|
||||
const self = this;
|
||||
for (let key in self) {
|
||||
if (key.toLowerCase() == prop.toLowerCase()) {
|
||||
return self[key];
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
enumerable: false
|
||||
});
|
||||
|
||||
export function slug(text) {
|
||||
if (LDAP.settings_get('LDAP_UTF8_NAMES_SLUGIFY') !== true) {
|
||||
return text;
|
||||
}
|
||||
text = slugify(text, '.');
|
||||
return text.replace(/[^0-9a-z-_.]/g, '');
|
||||
}
|
||||
|
||||
function templateVarHandler (variable, object) {
|
||||
|
||||
const templateRegex = /#{([\w\-]+)}/gi;
|
||||
let match = templateRegex.exec(variable);
|
||||
let tmpVariable = variable;
|
||||
|
||||
if (match == null) {
|
||||
if (!object.hasOwnProperty(variable)) {
|
||||
return;
|
||||
}
|
||||
return object[variable];
|
||||
} else {
|
||||
while (match != null) {
|
||||
const tmplVar = match[0];
|
||||
const tmplAttrName = match[1];
|
||||
|
||||
if (!object.hasOwnProperty(tmplAttrName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attrVal = object[tmplAttrName];
|
||||
tmpVariable = tmpVariable.replace(tmplVar, attrVal);
|
||||
match = templateRegex.exec(variable);
|
||||
}
|
||||
return tmpVariable;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPropertyValue(obj, key) {
|
||||
try {
|
||||
return _.reduce(key.split('.'), (acc, el) => acc[el], obj);
|
||||
} catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLdapUsername(ldapUser) {
|
||||
const usernameField = LDAP.settings_get('LDAP_USERNAME_FIELD');
|
||||
|
||||
if (usernameField.indexOf('#{') > -1) {
|
||||
return usernameField.replace(/#{(.+?)}/g, function(match, field) {
|
||||
return ldapUser.getLDAPValue(field);
|
||||
});
|
||||
}
|
||||
|
||||
return ldapUser.getLDAPValue(usernameField);
|
||||
}
|
||||
|
||||
export function getLdapEmail(ldapUser) {
|
||||
const emailField = LDAP.settings_get('LDAP_EMAIL_FIELD');
|
||||
|
||||
if (emailField.indexOf('#{') > -1) {
|
||||
return emailField.replace(/#{(.+?)}/g, function(match, field) {
|
||||
return ldapUser.getLDAPValue(field);
|
||||
});
|
||||
}
|
||||
|
||||
const ldapMail = ldapUser.getLDAPValue(emailField);
|
||||
if (typeof ldapMail === 'string') {
|
||||
return ldapMail;
|
||||
} else {
|
||||
return ldapMail[0].toString();
|
||||
}
|
||||
}
|
||||
|
||||
export function getLdapFullname(ldapUser) {
|
||||
const fullnameField = LDAP.settings_get('LDAP_FULLNAME_FIELD');
|
||||
if (fullnameField.indexOf('#{') > -1) {
|
||||
return fullnameField.replace(/#{(.+?)}/g, function(match, field) {
|
||||
return ldapUser.getLDAPValue(field);
|
||||
});
|
||||
}
|
||||
return ldapUser.getLDAPValue(fullnameField);
|
||||
}
|
||||
|
||||
export function getLdapUserUniqueID(ldapUser) {
|
||||
let Unique_Identifier_Field = LDAP.settings_get('LDAP_UNIQUE_IDENTIFIER_FIELD');
|
||||
|
||||
if (Unique_Identifier_Field !== '') {
|
||||
Unique_Identifier_Field = Unique_Identifier_Field.replace(/\s/g, '').split(',');
|
||||
} else {
|
||||
Unique_Identifier_Field = [];
|
||||
}
|
||||
|
||||
let User_Search_Field = LDAP.settings_get('LDAP_USER_SEARCH_FIELD');
|
||||
|
||||
if (User_Search_Field !== '') {
|
||||
User_Search_Field = User_Search_Field.replace(/\s/g, '').split(',');
|
||||
} else {
|
||||
User_Search_Field = [];
|
||||
}
|
||||
|
||||
Unique_Identifier_Field = Unique_Identifier_Field.concat(User_Search_Field);
|
||||
|
||||
if (Unique_Identifier_Field.length > 0) {
|
||||
Unique_Identifier_Field = Unique_Identifier_Field.find((field) => {
|
||||
return !_.isEmpty(ldapUser._raw.getLDAPValue(field));
|
||||
});
|
||||
if (Unique_Identifier_Field) {
|
||||
log_debug(`Identifying user with: ${ Unique_Identifier_Field}`);
|
||||
Unique_Identifier_Field = {
|
||||
attribute: Unique_Identifier_Field,
|
||||
value: ldapUser._raw.getLDAPValue(Unique_Identifier_Field).toString('hex'),
|
||||
};
|
||||
}
|
||||
return Unique_Identifier_Field;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDataToSyncUserData(ldapUser, user) {
|
||||
const syncUserData = LDAP.settings_get('LDAP_SYNC_USER_DATA');
|
||||
const syncUserDataFieldMap = LDAP.settings_get('LDAP_SYNC_USER_DATA_FIELDMAP').trim();
|
||||
|
||||
const userData = {};
|
||||
|
||||
if (syncUserData && syncUserDataFieldMap) {
|
||||
const whitelistedUserFields = ['email', 'name', 'customFields'];
|
||||
const fieldMap = JSON.parse(syncUserDataFieldMap);
|
||||
const emailList = [];
|
||||
_.map(fieldMap, function(userField, ldapField) {
|
||||
log_debug(`Mapping field ${ldapField} -> ${userField}`);
|
||||
switch (userField) {
|
||||
case 'email':
|
||||
if (!ldapUser.hasOwnProperty(ldapField)) {
|
||||
log_debug(`user does not have attribute: ${ ldapField }`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_.isObject(ldapUser[ldapField])) {
|
||||
_.map(ldapUser[ldapField], function(item) {
|
||||
emailList.push({ address: item, verified: true });
|
||||
});
|
||||
} else {
|
||||
emailList.push({ address: ldapUser[ldapField], verified: true });
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
const [outerKey, innerKeys] = userField.split(/\.(.+)/);
|
||||
|
||||
if (!_.find(whitelistedUserFields, (el) => el === outerKey)) {
|
||||
log_debug(`user attribute not whitelisted: ${ userField }`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (outerKey === 'customFields') {
|
||||
let customFieldsMeta;
|
||||
|
||||
try {
|
||||
customFieldsMeta = JSON.parse(LDAP.settings_get('Accounts_CustomFields'));
|
||||
} catch (e) {
|
||||
log_debug('Invalid JSON for Custom Fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getPropertyValue(customFieldsMeta, innerKeys)) {
|
||||
log_debug(`user attribute does not exist: ${ userField }`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const tmpUserField = getPropertyValue(user, userField);
|
||||
const tmpLdapField = templateVarHandler(ldapField, ldapUser);
|
||||
|
||||
if (tmpLdapField && tmpUserField !== tmpLdapField) {
|
||||
// creates the object structure instead of just assigning 'tmpLdapField' to
|
||||
// 'userData[userField]' in order to avoid the "cannot use the part (...)
|
||||
// to traverse the element" (MongoDB) error that can happen. Do not handle
|
||||
// arrays.
|
||||
// TODO: Find a better solution.
|
||||
const dKeys = userField.split('.');
|
||||
const lastKey = _.last(dKeys);
|
||||
_.reduce(dKeys, (obj, currKey) =>
|
||||
(currKey === lastKey)
|
||||
? obj[currKey] = tmpLdapField
|
||||
: obj[currKey] = obj[currKey] || {}
|
||||
, userData);
|
||||
log_debug(`user.${ userField } changed to: ${ tmpLdapField }`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (emailList.length > 0) {
|
||||
if (JSON.stringify(user.emails) !== JSON.stringify(emailList)) {
|
||||
userData.emails = emailList;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueId = getLdapUserUniqueID(ldapUser);
|
||||
|
||||
if (uniqueId && (!user.services || !user.services.ldap || user.services.ldap.id !== uniqueId.value || user.services.ldap.idAttribute !== uniqueId.attribute)) {
|
||||
userData['services.ldap.id'] = uniqueId.value;
|
||||
userData['services.ldap.idAttribute'] = uniqueId.attribute;
|
||||
}
|
||||
|
||||
if (user.authenticationMethod !== 'ldap') {
|
||||
userData.ldap = true;
|
||||
}
|
||||
|
||||
if (_.size(userData)) {
|
||||
return userData;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function syncUserData(user, ldapUser) {
|
||||
log_info('Syncing user data');
|
||||
log_debug('user', {'email': user.email, '_id': user._id});
|
||||
// log_debug('ldapUser', ldapUser.object);
|
||||
|
||||
if (LDAP.settings_get('LDAP_USERNAME_FIELD') !== '') {
|
||||
const username = slug(getLdapUsername(ldapUser));
|
||||
if (user && user._id && username !== user.username) {
|
||||
log_info('Syncing user username', user.username, '->', username);
|
||||
Meteor.users.findOne({ _id: user._id }, { $set: { username }});
|
||||
}
|
||||
}
|
||||
|
||||
if (LDAP.settings_get('LDAP_FULLNAME_FIELD') !== '') {
|
||||
const fullname= getLdapFullname(ldapUser);
|
||||
log_debug('fullname=',fullname);
|
||||
if (user && user._id && fullname !== '') {
|
||||
log_info('Syncing user fullname:', fullname);
|
||||
Meteor.users.update({ _id: user._id }, { $set: { 'profile.fullname' : fullname, }});
|
||||
}
|
||||
}
|
||||
|
||||
if (LDAP.settings_get('LDAP_EMAIL_FIELD') !== '') {
|
||||
const email = getLdapEmail(ldapUser);
|
||||
log_debug('email=', email);
|
||||
|
||||
if (user && user._id && email !== '') {
|
||||
log_info('Syncing user email:', email);
|
||||
Meteor.users.update({
|
||||
_id: user._id
|
||||
}, {
|
||||
$set: {
|
||||
'emails.0.address': email,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function addLdapUser(ldapUser, username, password) {
|
||||
const uniqueId = getLdapUserUniqueID(ldapUser);
|
||||
|
||||
const userObject = {
|
||||
};
|
||||
|
||||
if (username) {
|
||||
userObject.username = username;
|
||||
}
|
||||
|
||||
const userData = getDataToSyncUserData(ldapUser, {});
|
||||
|
||||
if (userData && userData.emails && userData.emails[0] && userData.emails[0].address) {
|
||||
if (Array.isArray(userData.emails[0].address)) {
|
||||
userObject.email = userData.emails[0].address[0];
|
||||
} else {
|
||||
userObject.email = userData.emails[0].address;
|
||||
}
|
||||
} else if (ldapUser.mail && ldapUser.mail.indexOf('@') > -1) {
|
||||
userObject.email = ldapUser.mail;
|
||||
} else if (LDAP.settings_get('LDAP_DEFAULT_DOMAIN') !== '') {
|
||||
userObject.email = `${ username || uniqueId.value }@${ LDAP.settings_get('LDAP_DEFAULT_DOMAIN') }`;
|
||||
} else {
|
||||
const error = new Meteor.Error('LDAP-login-error', 'LDAP Authentication succeded, there is no email to create an account. Have you tried setting your Default Domain in LDAP Settings?');
|
||||
log_error(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
log_debug('New user data', userObject);
|
||||
|
||||
if (password) {
|
||||
userObject.password = password;
|
||||
}
|
||||
|
||||
try {
|
||||
// This creates the account with password service
|
||||
userObject.ldap = true;
|
||||
userObject._id = Accounts.createUser(userObject);
|
||||
|
||||
// Add the services.ldap identifiers
|
||||
Meteor.users.update({ _id: userObject._id }, {
|
||||
$set: {
|
||||
'services.ldap': { id: uniqueId.value },
|
||||
'emails.0.verified': true,
|
||||
'authenticationMethod': 'ldap',
|
||||
}});
|
||||
} catch (error) {
|
||||
log_error('Error creating user', error);
|
||||
return error;
|
||||
}
|
||||
|
||||
syncUserData(userObject, ldapUser);
|
||||
|
||||
return {
|
||||
userId: userObject._id,
|
||||
};
|
||||
}
|
||||
|
||||
export function importNewUsers(ldap) {
|
||||
if (LDAP.settings_get('LDAP_ENABLE') !== true) {
|
||||
log_error('Can\'t run LDAP Import, LDAP is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ldap) {
|
||||
ldap = new LDAP();
|
||||
ldap.connectSync();
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
ldap.searchUsersSync('*', Meteor.bindEnvironment((error, ldapUsers, {next, end} = {}) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
ldapUsers.forEach((ldapUser) => {
|
||||
count++;
|
||||
|
||||
const uniqueId = getLdapUserUniqueID(ldapUser);
|
||||
// Look to see if user already exists
|
||||
const userQuery = {
|
||||
'services.ldap.id': uniqueId.value,
|
||||
};
|
||||
|
||||
log_debug('userQuery', userQuery);
|
||||
|
||||
let username;
|
||||
if (LDAP.settings_get('LDAP_USERNAME_FIELD') !== '') {
|
||||
username = slug(getLdapUsername(ldapUser));
|
||||
}
|
||||
|
||||
// Add user if it was not added before
|
||||
let user = Meteor.users.findOne(userQuery);
|
||||
|
||||
if (!user && username && LDAP.settings_get('LDAP_MERGE_EXISTING_USERS') === true) {
|
||||
const userQuery = {
|
||||
username,
|
||||
};
|
||||
|
||||
log_debug('userQuery merge', userQuery);
|
||||
|
||||
user = Meteor.users.findOne(userQuery);
|
||||
if (user) {
|
||||
syncUserData(user, ldapUser);
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
addLdapUser(ldapUser, username);
|
||||
}
|
||||
|
||||
if (count % 100 === 0) {
|
||||
log_info('Import running. Users imported until now:', count);
|
||||
}
|
||||
});
|
||||
|
||||
if (end) {
|
||||
log_info('Import finished. Users imported:', count);
|
||||
}
|
||||
|
||||
next(count);
|
||||
}));
|
||||
}
|
||||
|
||||
function sync() {
|
||||
if (LDAP.settings_get('LDAP_ENABLE') !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ldap = new LDAP();
|
||||
|
||||
try {
|
||||
ldap.connectSync();
|
||||
|
||||
let users;
|
||||
if (LDAP.settings_get('LDAP_BACKGROUND_SYNC_KEEP_EXISTANT_USERS_UPDATED') === true) {
|
||||
users = Meteor.users.find({ 'services.ldap': { $exists: true }});
|
||||
}
|
||||
|
||||
if (LDAP.settings_get('LDAP_BACKGROUND_SYNC_IMPORT_NEW_USERS') === true) {
|
||||
importNewUsers(ldap);
|
||||
}
|
||||
|
||||
if (LDAP.settings_get('LDAP_BACKGROUND_SYNC_KEEP_EXISTANT_USERS_UPDATED') === true) {
|
||||
users.forEach(function(user) {
|
||||
let ldapUser;
|
||||
|
||||
if (user.services && user.services.ldap && user.services.ldap.id) {
|
||||
ldapUser = ldap.getUserByIdSync(user.services.ldap.id, user.services.ldap.idAttribute);
|
||||
} else {
|
||||
ldapUser = ldap.getUserByUsernameSync(user.username);
|
||||
}
|
||||
|
||||
if (ldapUser) {
|
||||
syncUserData(user, ldapUser);
|
||||
} else {
|
||||
log_info('Can\'t sync user', user.username);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
return error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const jobName = 'LDAP_Sync';
|
||||
|
||||
const addCronJob = _.debounce(Meteor.bindEnvironment(function addCronJobDebounced() {
|
||||
let sc=SyncedCron.SyncedCron; //Why ?? something must be wrong in the import
|
||||
if (LDAP.settings_get('LDAP_BACKGROUND_SYNC') !== true) {
|
||||
log_info('Disabling LDAP Background Sync');
|
||||
if (sc.nextScheduledAtDate(jobName)) {
|
||||
sc.remove(jobName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
log_info('Enabling LDAP Background Sync');
|
||||
sc.add({
|
||||
name: jobName,
|
||||
schedule: function(parser) {
|
||||
if (LDAP.settings_get('LDAP_BACKGROUND_SYNC_INTERVAL')) {
|
||||
return parser.text(LDAP.settings_get('LDAP_BACKGROUND_SYNC_INTERVAL'));
|
||||
}
|
||||
else {
|
||||
return parser.recur().on(0).minute();
|
||||
}},
|
||||
job: function() {
|
||||
sync();
|
||||
},
|
||||
});
|
||||
sc.start();
|
||||
|
||||
}), 500);
|
||||
|
||||
Meteor.startup(() => {
|
||||
Meteor.defer(() => {
|
||||
if(LDAP.settings_get('LDAP_BACKGROUND_SYNC')){addCronJob();}
|
||||
});
|
||||
});
|
29
packages/wekan-ldap/server/syncUser.js
Normal file
29
packages/wekan-ldap/server/syncUser.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
import {importNewUsers} from './sync';
|
||||
import LDAP from './ldap';
|
||||
|
||||
Meteor.methods({
|
||||
ldap_sync_now() {
|
||||
const user = Meteor.user();
|
||||
if (!user) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'ldap_sync_users' });
|
||||
}
|
||||
|
||||
//TODO: This needs to be fixed - security issue -> alanning:meteor-roles
|
||||
//if (!RocketChat.authz.hasRole(user._id, 'admin')) {
|
||||
// throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'ldap_sync_users' });
|
||||
//}
|
||||
|
||||
if (LDAP.settings_get('LDAP_ENABLE') !== true) {
|
||||
throw new Meteor.Error('LDAP_disabled');
|
||||
}
|
||||
|
||||
this.unblock();
|
||||
|
||||
importNewUsers();
|
||||
|
||||
return {
|
||||
message: 'Sync_in_progress',
|
||||
params: [],
|
||||
};
|
||||
},
|
||||
});
|
39
packages/wekan-ldap/server/testConnection.js
Normal file
39
packages/wekan-ldap/server/testConnection.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
import LDAP from './ldap';
|
||||
|
||||
Meteor.methods({
|
||||
ldap_test_connection() {
|
||||
const user = Meteor.user();
|
||||
if (!user) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'ldap_test_connection' });
|
||||
}
|
||||
|
||||
//TODO: This needs to be fixed - security issue -> alanning:meteor-roles
|
||||
//if (!RocketChat.authz.hasRole(user._id, 'admin')) {
|
||||
// throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'ldap_test_connection' });
|
||||
//}
|
||||
|
||||
if (LDAP.settings_get(LDAP_ENABLE) !== true) {
|
||||
throw new Meteor.Error('LDAP_disabled');
|
||||
}
|
||||
|
||||
let ldap;
|
||||
try {
|
||||
ldap = new LDAP();
|
||||
ldap.connectSync();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw new Meteor.Error(error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
ldap.bindIfNecessary();
|
||||
} catch (error) {
|
||||
throw new Meteor.Error(error.name || error.message);
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Connection_success',
|
||||
params: [],
|
||||
};
|
||||
},
|
||||
});
|
1
packages/wekan-oidc/.gitignore
vendored
Normal file
1
packages/wekan-oidc/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
.versions
|
14
packages/wekan-oidc/LICENSE.txt
Normal file
14
packages/wekan-oidc/LICENSE.txt
Normal file
|
@ -0,0 +1,14 @@
|
|||
Copyright (C) 2016 SWITCH
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
52
packages/wekan-oidc/README.md
Normal file
52
packages/wekan-oidc/README.md
Normal file
|
@ -0,0 +1,52 @@
|
|||
# salleman:oidc package
|
||||
|
||||
A Meteor implementation of OpenID Connect Login flow
|
||||
|
||||
## Usage and Documentation
|
||||
|
||||
Look at the `salleman:accounts-oidc` package for the documentation about using OpenID Connect with Meteor.
|
||||
|
||||
## Usage with e.g. authentik for updating users via oidc
|
||||
|
||||
To use the following features set:
|
||||
'export PROPAGATE_OIDC_DATA=true'
|
||||
|
||||
SIMPLE: If user is assigned to 'group in authentik' it will be automatically assigned to corresponding team in wekan if exists
|
||||
|
||||
ADVANCED: Users can be assigned to teams or organisations via oidc on login. Teams and organisations that do not exist in wekan, yet, will be created, when specified. Admin privileges for wekan through a specific group can be set via Oidc.
|
||||
See example below:
|
||||
|
||||
|
||||
1. Specify scope in authentik for what will be delivered via userinfo["wekanGroups"]
|
||||
|
||||
Possible configuration for *yourScope*:
|
||||
'
|
||||
groupsDict = {"wekanGroups": []}
|
||||
for group in request.user.ak_groups.all():
|
||||
groupDict = {"displayName": group.name}
|
||||
groupAdmin = {"isAdmin": group.isAdmin}
|
||||
groupAttributes = group.attributes
|
||||
tmp_dict= groupDict | groupAttributes | groupAdmin
|
||||
|
||||
groupsDict["wekanGroups"].append(tmp_dict)
|
||||
return groupsDict
|
||||
'
|
||||
2. Tell provider to include *yourScope* and set
|
||||
OAUTH2_REQUEST_PERMISSIONS="openid profile email *yourScope*"
|
||||
|
||||
3. In your group settings in authentik add attributes:
|
||||
desc: groupDesc // default group.name
|
||||
isAdmin: true // default false
|
||||
website: groupWebsite // default group.name
|
||||
isActive: true // default false
|
||||
shortName: groupShortname // default group.name
|
||||
forceCreate: true // default false
|
||||
isOrganisation: true // default false
|
||||
|
||||
4. On next login user will be added to either newly created group/organization or to already existing
|
||||
|
||||
NOTE: orgs & teams won't be updated if they already exist.
|
||||
|
||||
5. Manages admin rights as well. If user is in Group which has isAdmin: set to true, user will get admin
|
||||
privileges in Wekan as well.
|
||||
If no adjustments (e.g. 1-3) are made on oidc provider's side, user will receive his/her admin rights from before.
|
179
packages/wekan-oidc/loginHandler.js
Normal file
179
packages/wekan-oidc/loginHandler.js
Normal file
|
@ -0,0 +1,179 @@
|
|||
// creates Object if not present in collection
|
||||
// initArr = [displayName, shortName, website, isActive]
|
||||
// objString = ["Org","Team"] for method mapping
|
||||
function createObject(initArr, objString)
|
||||
{
|
||||
functionName = objString === "Org" ? 'setCreateOrgFromOidc' : 'setCreateTeamFromOidc';
|
||||
creationString = 'setCreate'+ objString + 'FromOidc';
|
||||
return Meteor.call(functionName,
|
||||
initArr[0],//displayName
|
||||
initArr[1],//desc
|
||||
initArr[2],//shortName
|
||||
initArr[3],//website
|
||||
initArr[4]//xxxisActive
|
||||
);
|
||||
}
|
||||
function updateObject(initArr, objString)
|
||||
{
|
||||
functionName = objString === "Org" ? 'setOrgAllFieldsFromOidc' : 'setTeamAllFieldsFromOidc';
|
||||
return Meteor.call(functionName,
|
||||
initArr[0],//team || org Object
|
||||
initArr[1],//displayName
|
||||
initArr[2],//desc
|
||||
initArr[3],//shortName
|
||||
initArr[4],//website
|
||||
initArr[5]//xxxisActive
|
||||
);
|
||||
}
|
||||
//checks whether obj is in collection of userObjs
|
||||
//params
|
||||
//e.g. userObjs = user.teams
|
||||
//e.g. obj = Team.findOne...
|
||||
//e.g. collection = "team"
|
||||
function contains(userObjs, obj, collection)
|
||||
{
|
||||
id = collection+'Id';
|
||||
|
||||
if(typeof userObjs == "undefined" || !userObjs.length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for (const [count, hash] of Object.entries(userObjs))
|
||||
{
|
||||
if (hash[id] === obj._id)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
module.exports = {
|
||||
|
||||
// This function adds groups as organizations or teams to users and
|
||||
// creates them if not already existing
|
||||
// DEFAULT after creation orgIsActive & teamIsActive: true
|
||||
// PODC provider needs to send group data within "wekanGroup" scope
|
||||
// PARAMS to be set for groups within your Oidc provider:
|
||||
// isAdmin: [true, false] -> admin group becomes admin in wekan
|
||||
// isOrganization: [true, false] -> creates org and adds to user
|
||||
// displayName: "string"
|
||||
addGroupsWithAttributes: function (user, groups){
|
||||
teamArray=[];
|
||||
orgArray=[];
|
||||
isAdmin = [];
|
||||
teams = user.teams;
|
||||
orgs = user.orgs;
|
||||
for (group of groups)
|
||||
{
|
||||
initAttributes = [
|
||||
group.displayName,
|
||||
group.desc || group.displayName,
|
||||
group.shortName ||group.displayName,
|
||||
group.website || group.displayName, group.isActive || false];
|
||||
|
||||
isOrg = group.isOrganisation || false;
|
||||
forceCreate = group.forceCreate|| false;
|
||||
isAdmin.push(group.isAdmin || false);
|
||||
if (isOrg)
|
||||
{
|
||||
org = Org.findOne({"orgDisplayName": group.displayName});
|
||||
if(org)
|
||||
{
|
||||
if(contains(orgs, org, "org"))
|
||||
{
|
||||
initAttributes.unshift(org);
|
||||
updateObject(initAttributes, "Org");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if(forceCreate)
|
||||
{
|
||||
createObject(initAttributes, "Org");
|
||||
org = Org.findOne({'orgDisplayName': group.displayName});
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
orgHash = {'orgId': org._id, 'orgDisplayName': group.displayName};
|
||||
orgArray.push(orgHash);
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
//start team routine
|
||||
team = Team.findOne({"teamDisplayName": group.displayName});
|
||||
if (team)
|
||||
{
|
||||
if(contains(teams, team, "team"))
|
||||
{
|
||||
initAttributes.unshift(team);
|
||||
updateObject(initAttributes, "Team");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if(forceCreate)
|
||||
{
|
||||
createObject(initAttributes, "Team");
|
||||
team = Team.findOne({'teamDisplayName': group.displayName});
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
teamHash = {'teamId': team._id, 'teamDisplayName': group.displayName};
|
||||
teamArray.push(teamHash);
|
||||
}
|
||||
}
|
||||
// user is assigned to team/org which has set isAdmin: true in oidc data
|
||||
// hence user will get admin privileges in wekan
|
||||
// E.g. Admin rights will be withdrawn if no group in oidc provider has isAdmin set to true
|
||||
|
||||
users.update({ _id: user._id }, { $set: {isAdmin: isAdmin.some(i => (i === true))}});
|
||||
teams = {'teams': {'$each': teamArray}};
|
||||
orgs = {'orgs': {'$each': orgArray}};
|
||||
users.update({ _id: user._id }, { $push: teams});
|
||||
users.update({ _id: user._id }, { $push: orgs});
|
||||
// remove temporary oidc data from user collection
|
||||
users.update({ _id: user._id }, { $unset: {"services.oidc.groups": []}});
|
||||
|
||||
return;
|
||||
},
|
||||
|
||||
changeUsername: function(user, name)
|
||||
{
|
||||
username = {'username': name};
|
||||
if (user.username != username) users.update({ _id: user._id }, { $set: username});
|
||||
},
|
||||
changeFullname: function(user, name)
|
||||
{
|
||||
username = {'profile.fullname': name};
|
||||
if (user.username != username) users.update({ _id: user._id }, { $set: username});
|
||||
},
|
||||
addEmail: function(user, email)
|
||||
{
|
||||
user_email = user.emails || [];
|
||||
var contained = false;
|
||||
position = 0;
|
||||
for (const [count, mail_hash] of Object.entries(user_email))
|
||||
{
|
||||
if (mail_hash['address'] === email)
|
||||
{
|
||||
contained = true;
|
||||
position = count;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(contained && position != 0)
|
||||
{
|
||||
user_email.splice(position,1);
|
||||
contained = false;
|
||||
}
|
||||
if(!contained)
|
||||
{
|
||||
user_email.unshift({'address': email, 'verified': true});
|
||||
user_email = {'emails': user_email};
|
||||
users.update({ _id: user._id }, { $set: user_email});
|
||||
}
|
||||
}
|
||||
}
|
67
packages/wekan-oidc/oidc_client.js
Normal file
67
packages/wekan-oidc/oidc_client.js
Normal file
|
@ -0,0 +1,67 @@
|
|||
Oidc = {};
|
||||
|
||||
// Request OpenID Connect credentials for the user
|
||||
// @param options {optional}
|
||||
// @param credentialRequestCompleteCallback {Function} Callback function to call on
|
||||
// completion. Takes one argument, credentialToken on success, or Error on
|
||||
// error.
|
||||
Oidc.requestCredential = function (options, credentialRequestCompleteCallback) {
|
||||
// support both (options, callback) and (callback).
|
||||
if (!credentialRequestCompleteCallback && typeof options === 'function') {
|
||||
credentialRequestCompleteCallback = options;
|
||||
options = {};
|
||||
}
|
||||
|
||||
var config = ServiceConfiguration.configurations.findOne({service: 'oidc'});
|
||||
if (!config) {
|
||||
credentialRequestCompleteCallback && credentialRequestCompleteCallback(
|
||||
new ServiceConfiguration.ConfigError('Service oidc not configured.'));
|
||||
return;
|
||||
}
|
||||
|
||||
var credentialToken = Random.secret();
|
||||
var loginStyle = OAuth._loginStyle('oidc', config, options);
|
||||
|
||||
// options
|
||||
options = options || {};
|
||||
options.client_id = config.clientId;
|
||||
options.response_type = options.response_type || 'code';
|
||||
options.redirect_uri = OAuth._redirectUri('oidc', config);
|
||||
options.state = OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl);
|
||||
options.scope = config.requestPermissions || 'openid profile email';
|
||||
|
||||
if (config.loginStyle && config.loginStyle == 'popup') {
|
||||
options.display = 'popup';
|
||||
}
|
||||
|
||||
var loginUrl = config.serverUrl + config.authorizationEndpoint;
|
||||
// check if the loginUrl already contains a "?"
|
||||
var first = loginUrl.indexOf('?') === -1;
|
||||
for (var k in options) {
|
||||
if (first) {
|
||||
loginUrl += '?';
|
||||
first = false;
|
||||
}
|
||||
else {
|
||||
loginUrl += '&'
|
||||
}
|
||||
loginUrl += encodeURIComponent(k) + '=' + encodeURIComponent(options[k]);
|
||||
}
|
||||
|
||||
//console.log('XXX: loginURL: ' + loginUrl)
|
||||
|
||||
options.popupOptions = options.popupOptions || {};
|
||||
var popupOptions = {
|
||||
width: options.popupOptions.width || 320,
|
||||
height: options.popupOptions.height || 450
|
||||
};
|
||||
|
||||
OAuth.launchLogin({
|
||||
loginService: 'oidc',
|
||||
loginStyle: loginStyle,
|
||||
loginUrl: loginUrl,
|
||||
credentialRequestCompleteCallback: credentialRequestCompleteCallback,
|
||||
credentialToken: credentialToken,
|
||||
popupOptions: popupOptions,
|
||||
});
|
||||
};
|
6
packages/wekan-oidc/oidc_configure.html
Normal file
6
packages/wekan-oidc/oidc_configure.html
Normal file
|
@ -0,0 +1,6 @@
|
|||
<template name="configureLoginServiceDialogForOidc">
|
||||
<p>
|
||||
You'll need to create an OpenID Connect client configuration with your provider.
|
||||
Set App Callbacks URLs to: <span class="url">{{siteUrl}}_oauth/oidc</span>
|
||||
</p>
|
||||
</template>
|
17
packages/wekan-oidc/oidc_configure.js
Normal file
17
packages/wekan-oidc/oidc_configure.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
Template.configureLoginServiceDialogForOidc.helpers({
|
||||
siteUrl: function () {
|
||||
return Meteor.absoluteUrl();
|
||||
}
|
||||
});
|
||||
|
||||
Template.configureLoginServiceDialogForOidc.fields = function () {
|
||||
return [
|
||||
{ property: 'clientId', label: 'Client ID'},
|
||||
{ property: 'secret', label: 'Client Secret'},
|
||||
{ property: 'serverUrl', label: 'OIDC Server URL'},
|
||||
{ property: 'authorizationEndpoint', label: 'Authorization Endpoint'},
|
||||
{ property: 'tokenEndpoint', label: 'Token Endpoint'},
|
||||
{ property: 'userinfoEndpoint', label: 'Userinfo Endpoint'},
|
||||
{ property: 'idTokenWhitelistFields', label: 'Id Token Fields'}
|
||||
];
|
||||
};
|
306
packages/wekan-oidc/oidc_server.js
Normal file
306
packages/wekan-oidc/oidc_server.js
Normal file
|
@ -0,0 +1,306 @@
|
|||
import {addGroupsWithAttributes, addEmail, changeFullname, changeUsername} from './loginHandler';
|
||||
|
||||
Oidc = {};
|
||||
httpCa = false;
|
||||
|
||||
if (process.env.OAUTH2_CA_CERT !== undefined) {
|
||||
try {
|
||||
const fs = Npm.require('fs');
|
||||
if (fs.existsSync(process.env.OAUTH2_CA_CERT)) {
|
||||
httpCa = fs.readFileSync(process.env.OAUTH2_CA_CERT);
|
||||
}
|
||||
} catch(e) {
|
||||
console.log('WARNING: failed loading: ' + process.env.OAUTH2_CA_CERT);
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
var profile = {};
|
||||
var serviceData = {};
|
||||
var userinfo = {};
|
||||
|
||||
OAuth.registerService('oidc', 2, null, function (query) {
|
||||
|
||||
var debug = process.env.DEBUG || false;
|
||||
|
||||
var token = getToken(query);
|
||||
if (debug) console.log('XXX: register token:', token);
|
||||
|
||||
var accessToken = token.access_token || token.id_token;
|
||||
var expiresAt = (+new Date) + (1000 * parseInt(token.expires_in, 10));
|
||||
|
||||
var claimsInAccessToken = (process.env.OAUTH2_ADFS_ENABLED === 'true' || process.env.OAUTH2_ADFS_ENABLED === true) || false;
|
||||
|
||||
if(claimsInAccessToken)
|
||||
{
|
||||
// hack when using custom claims in the accessToken. On premise ADFS
|
||||
userinfo = getTokenContent(accessToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// normal behaviour, getting the claims from UserInfo endpoint.
|
||||
userinfo = getUserInfo(accessToken);
|
||||
}
|
||||
|
||||
if (userinfo.ocs) userinfo = userinfo.ocs.data; // Nextcloud hack
|
||||
if (userinfo.metadata) userinfo = userinfo.metadata // Openshift hack
|
||||
if (debug) console.log('XXX: userinfo:', userinfo);
|
||||
|
||||
serviceData.id = userinfo[process.env.OAUTH2_ID_MAP]; // || userinfo["id"];
|
||||
serviceData.username = userinfo[process.env.OAUTH2_USERNAME_MAP]; // || userinfo["uid"];
|
||||
serviceData.fullname = userinfo[process.env.OAUTH2_FULLNAME_MAP]; // || userinfo["displayName"];
|
||||
serviceData.accessToken = accessToken;
|
||||
serviceData.expiresAt = expiresAt;
|
||||
|
||||
|
||||
// If on Oracle OIM email is empty or null, get info from username
|
||||
if (process.env.ORACLE_OIM_ENABLED === 'true' || process.env.ORACLE_OIM_ENABLED === true) {
|
||||
if (userinfo[process.env.OAUTH2_EMAIL_MAP]) {
|
||||
serviceData.email = userinfo[process.env.OAUTH2_EMAIL_MAP];
|
||||
} else {
|
||||
serviceData.email = userinfo[process.env.OAUTH2_USERNAME_MAP];
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.ORACLE_OIM_ENABLED !== 'true' && process.env.ORACLE_OIM_ENABLED !== true) {
|
||||
serviceData.email = userinfo[process.env.OAUTH2_EMAIL_MAP]; // || userinfo["email"];
|
||||
}
|
||||
|
||||
if (accessToken) {
|
||||
var tokenContent = getTokenContent(accessToken);
|
||||
var fields = _.pick(tokenContent, getConfiguration().idTokenWhitelistFields);
|
||||
_.extend(serviceData, fields);
|
||||
}
|
||||
|
||||
if (token.refresh_token)
|
||||
serviceData.refreshToken = token.refresh_token;
|
||||
if (debug) console.log('XXX: serviceData:', serviceData);
|
||||
|
||||
profile.name = userinfo[process.env.OAUTH2_FULLNAME_MAP]; // || userinfo["displayName"];
|
||||
profile.email = userinfo[process.env.OAUTH2_EMAIL_MAP]; // || userinfo["email"];
|
||||
if (debug) console.log('XXX: profile:', profile);
|
||||
|
||||
|
||||
//temporarily store data from oidc in user.services.oidc.groups to update groups
|
||||
serviceData.groups = (userinfo["groups"] && userinfo["wekanGroups"]) ? userinfo["wekanGroups"] : userinfo["groups"];
|
||||
|
||||
// groups arriving as array of strings indicate there is no scope set in oidc privider
|
||||
// to assign teams and keep admin privileges
|
||||
// data needs to be treated differently.
|
||||
// use case: in oidc provider no scope is set, hence no group attributes.
|
||||
// therefore: keep admin privileges for wekan as before
|
||||
if(Array.isArray(serviceData.groups) && serviceData.groups.length && typeof serviceData.groups[0] === "string" )
|
||||
{
|
||||
user = Meteor.users.findOne({'_id': serviceData.id});
|
||||
|
||||
serviceData.groups.forEach(function(groupName, i)
|
||||
{
|
||||
if(user?.isAdmin && i == 0)
|
||||
{
|
||||
// keep information of user.isAdmin since in loginHandler the user will // be updated regarding group admin privileges provided via oidc
|
||||
serviceData.groups[i] = {"isAdmin": true};
|
||||
serviceData.groups[i]["displayName"]= groupName;
|
||||
}
|
||||
else
|
||||
{
|
||||
serviceData.groups[i] = {"displayName": groupName};
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
serviceData: serviceData,
|
||||
options: { profile: profile }
|
||||
};
|
||||
});
|
||||
|
||||
var userAgent = "Meteor";
|
||||
if (Meteor.release) {
|
||||
userAgent += "/" + Meteor.release;
|
||||
}
|
||||
|
||||
if (process.env.ORACLE_OIM_ENABLED !== 'true' && process.env.ORACLE_OIM_ENABLED !== true) {
|
||||
var getToken = function (query) {
|
||||
var debug = process.env.DEBUG || false;
|
||||
var config = getConfiguration();
|
||||
if(config.tokenEndpoint.includes('https://')){
|
||||
var serverTokenEndpoint = config.tokenEndpoint;
|
||||
}else{
|
||||
var serverTokenEndpoint = config.serverUrl + config.tokenEndpoint;
|
||||
}
|
||||
var requestPermissions = config.requestPermissions;
|
||||
var response;
|
||||
|
||||
try {
|
||||
var postOptions = {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
"User-Agent": userAgent
|
||||
},
|
||||
params: {
|
||||
code: query.code,
|
||||
client_id: config.clientId,
|
||||
client_secret: OAuth.openSecret(config.secret),
|
||||
redirect_uri: OAuth._redirectUri('oidc', config),
|
||||
grant_type: 'authorization_code',
|
||||
state: query.state
|
||||
}
|
||||
};
|
||||
if (httpCa) {
|
||||
postOptions['npmRequestOptions'] = { ca: httpCa };
|
||||
}
|
||||
response = HTTP.post(serverTokenEndpoint, postOptions);
|
||||
} catch (err) {
|
||||
throw _.extend(new Error("Failed to get token from OIDC " + serverTokenEndpoint + ": " + err.message),
|
||||
{ response: err.response });
|
||||
}
|
||||
if (response.data.error) {
|
||||
// if the http response was a json object with an error attribute
|
||||
throw new Error("Failed to complete handshake with OIDC " + serverTokenEndpoint + ": " + response.data.error);
|
||||
} else {
|
||||
if (debug) console.log('XXX: getToken response: ', response.data);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.ORACLE_OIM_ENABLED === 'true' || process.env.ORACLE_OIM_ENABLED === true) {
|
||||
|
||||
var getToken = function (query) {
|
||||
var debug = (process.env.DEBUG === 'true' || process.env.DEBUG === true) || false;
|
||||
var config = getConfiguration();
|
||||
if(config.tokenEndpoint.includes('https://')){
|
||||
var serverTokenEndpoint = config.tokenEndpoint;
|
||||
}else{
|
||||
var serverTokenEndpoint = config.serverUrl + config.tokenEndpoint;
|
||||
}
|
||||
var requestPermissions = config.requestPermissions;
|
||||
var response;
|
||||
|
||||
// OIM needs basic Authentication token in the header - ClientID + SECRET in base64
|
||||
var dataToken=null;
|
||||
var strBasicToken=null;
|
||||
var strBasicToken64=null;
|
||||
|
||||
dataToken = process.env.OAUTH2_CLIENT_ID + ':' + process.env.OAUTH2_SECRET;
|
||||
strBasicToken = new Buffer(dataToken);
|
||||
strBasicToken64 = strBasicToken.toString('base64');
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
if (debug) console.log('Basic Token: ', strBasicToken64);
|
||||
|
||||
try {
|
||||
var postOptions = {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
"User-Agent": userAgent,
|
||||
"Authorization": "Basic " + strBasicToken64
|
||||
},
|
||||
params: {
|
||||
code: query.code,
|
||||
client_id: config.clientId,
|
||||
client_secret: OAuth.openSecret(config.secret),
|
||||
redirect_uri: OAuth._redirectUri('oidc', config),
|
||||
grant_type: 'authorization_code',
|
||||
state: query.state
|
||||
}
|
||||
};
|
||||
if (httpCa) {
|
||||
postOptions['npmRequestOptions'] = { ca: httpCa };
|
||||
}
|
||||
response = HTTP.post(serverTokenEndpoint, postOptions);
|
||||
} catch (err) {
|
||||
throw _.extend(new Error("Failed to get token from OIDC " + serverTokenEndpoint + ": " + err.message),
|
||||
{ response: err.response });
|
||||
}
|
||||
if (response.data.error) {
|
||||
// if the http response was a json object with an error attribute
|
||||
throw new Error("Failed to complete handshake with OIDC " + serverTokenEndpoint + ": " + response.data.error);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
if (debug) console.log('XXX: getToken response: ', response.data);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
var getUserInfo = function (accessToken) {
|
||||
var debug = process.env.DEBUG || false;
|
||||
var config = getConfiguration();
|
||||
// Some userinfo endpoints use a different base URL than the authorization or token endpoints.
|
||||
// This logic allows the end user to override the setting by providing the full URL to userinfo in their config.
|
||||
if (config.userinfoEndpoint.includes("https://")) {
|
||||
var serverUserinfoEndpoint = config.userinfoEndpoint;
|
||||
} else {
|
||||
var serverUserinfoEndpoint = config.serverUrl + config.userinfoEndpoint;
|
||||
}
|
||||
var response;
|
||||
try {
|
||||
var getOptions = {
|
||||
headers: {
|
||||
"User-Agent": userAgent,
|
||||
"Authorization": "Bearer " + accessToken
|
||||
}
|
||||
};
|
||||
if (httpCa) {
|
||||
getOptions['npmRequestOptions'] = { ca: httpCa };
|
||||
}
|
||||
response = HTTP.get(serverUserinfoEndpoint, getOptions);
|
||||
} catch (err) {
|
||||
throw _.extend(new Error("Failed to fetch userinfo from OIDC " + serverUserinfoEndpoint + ": " + err.message),
|
||||
{response: err.response});
|
||||
}
|
||||
if (debug) console.log('XXX: getUserInfo response: ', response.data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
var getConfiguration = function () {
|
||||
var config = ServiceConfiguration.configurations.findOne({ service: 'oidc' });
|
||||
if (!config) {
|
||||
throw new ServiceConfiguration.ConfigError('Service oidc not configured.');
|
||||
}
|
||||
return config;
|
||||
};
|
||||
|
||||
var getTokenContent = function (token) {
|
||||
var content = null;
|
||||
if (token) {
|
||||
try {
|
||||
var parts = token.split('.');
|
||||
var header = JSON.parse(Buffer.from(parts[0], 'base64').toString());
|
||||
content = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
var signature = Buffer.from(parts[2], 'base64');
|
||||
var signed = parts[0] + '.' + parts[1];
|
||||
} catch (err) {
|
||||
this.content = {
|
||||
exp: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
Meteor.methods({
|
||||
'groupRoutineOnLogin': function(info, userId)
|
||||
{
|
||||
check(info, Object);
|
||||
check(userId, String);
|
||||
var propagateOidcData = process.env.PROPAGATE_OIDC_DATA || false;
|
||||
if (propagateOidcData)
|
||||
{
|
||||
|
||||
users= Meteor.users;
|
||||
user = users.findOne({'_id': userId});
|
||||
if(user)
|
||||
{
|
||||
//updates/creates Groups and user admin privileges accordingly
|
||||
addGroupsWithAttributes(user, info.groups);
|
||||
if(info.email) addEmail(user, info.email);
|
||||
if(info.fullname) changeFullname(user, info.fullname);
|
||||
if(info.username) changeUsername(user, info.username);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Oidc.retrieveCredential = function (credentialToken, credentialSecret) {
|
||||
return OAuth.retrieveCredential(credentialToken, credentialSecret);
|
||||
};
|
24
packages/wekan-oidc/package.js
Normal file
24
packages/wekan-oidc/package.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
Package.describe({
|
||||
summary: "OpenID Connect (OIDC) flow for Meteor",
|
||||
version: "1.0.12",
|
||||
name: "wekan-oidc",
|
||||
git: "https://github.com/wekan/wekan-oidc.git",
|
||||
});
|
||||
|
||||
Package.onUse(function(api) {
|
||||
api.use('oauth2', ['client', 'server']);
|
||||
api.use('oauth', ['client', 'server']);
|
||||
api.use('http', ['server']);
|
||||
api.use('underscore', 'client');
|
||||
api.use('ecmascript');
|
||||
api.use('templating', 'client');
|
||||
api.use('random', 'client');
|
||||
api.use('service-configuration', ['client', 'server']);
|
||||
|
||||
api.export('Oidc');
|
||||
|
||||
api.addFiles(['oidc_configure.html', 'oidc_configure.js'], 'client');
|
||||
|
||||
api.addFiles('oidc_server.js', 'server');
|
||||
api.addFiles('oidc_client.js', 'client');
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue