Added back WeKan lockout, ldap, oidc, cas.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu 2022-05-16 21:12:27 +03:00
parent a73a4c1e5b
commit 00768b4392
45 changed files with 3966 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,2 @@
.build*
node_modules/

View 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.

View 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)

View 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;
};

View 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();
};

View 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);
}
}

View 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'
});

View 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.

View 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.

View file

@ -0,0 +1,126 @@
# Meteor - Accounts - Lockout
[![Build Status](https://travis-ci.org/LucasAntoniassi/meteor-accounts-lockout.svg?branch=master)](https://travis-ci.org/LucasAntoniassi/meteor-accounts-lockout)
[![Codacy Badge](https://api.codacy.com/project/badge/Grade/8ce60fa7e2c24891b9bdfc3b65433d23)](https://www.codacy.com/app/lucasantoniassi/meteor-accounts-lockout?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=LucasAntoniassi/meteor-accounts-lockout&amp;utm_campaign=Badge_Grade)
[![Code Climate](https://codeclimate.com/github/LucasAntoniassi/meteor-accounts-lockout/badges/gpa.svg)](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.
![you-shall-not-pass](https://cloud.githubusercontent.com/assets/3399956/9023729/007dd2a2-38b1-11e5-807a-b81c6ce00c80.jpg)
## 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).

View file

@ -0,0 +1,5 @@
import AccountsLockout from './src/accountsLockout';
const Name = 'wekan-accounts-lockout';
export { Name, AccountsLockout };

View 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');
});

View file

@ -0,0 +1,4 @@
{
"name": "wekan-accounts-lockout",
"private": true
}

View 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;

View file

@ -0,0 +1,3 @@
import { Meteor } from 'meteor/meteor';
export default new Meteor.Collection('AccountsLockout.Connections');

View 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;

View 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;

View file

@ -0,0 +1 @@
.versions

View 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.

View 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: []
}
}
);
});
}
```

View 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']
});
}

View 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==');
}

View 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');
});

View 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.

View 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
}
```

View 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();
}
},
});
};

View 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');
});

View file

@ -0,0 +1 @@
import './loginHandler';

View 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();
}
}

View 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 };

View 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;
});

View 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();}
});
});

View 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: [],
};
},
});

View 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
View file

@ -0,0 +1 @@
.versions

View 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.

View 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.

View 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});
}
}
}

View 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,
});
};

View 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>

View 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'}
];
};

View 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);
};

View 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');
});