Merge branch 'main' into wekan-ldap-async-migration

This commit is contained in:
Harry Adel 2026-02-13 17:11:16 +02:00 committed by GitHub
commit 6bf60c496b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
322 changed files with 13214 additions and 3443 deletions

View file

@ -93,6 +93,8 @@ Meteor.loginWithCas = function(options, callback) {
};
var openCenteredPopup = function(url, width, height) {
// #FIXME screenX and outerWidth are often different units on mobile screen or high DPI
// see https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio
var screenX = typeof window.screenX !== 'undefined'
? window.screenX : window.screenLeft;
var screenY = typeof window.screenY !== 'undefined'

View file

@ -1,9 +1,8 @@
"use strict";
const Fiber = Npm.require('fibers');
const https = Npm.require('https');
const url = Npm.require('url');
const xmlParser = Npm.require('xml2js');
import https from 'https';
import url from 'url';
import xml2js from 'xml2js';
// Library
class CAS {
@ -61,7 +60,7 @@ class CAS {
console.log(error);
callback(undefined, false);
} else {
xmlParser.parseString(response, (err, result) => {
xml2js.parseString(response, (err, result) => {
if (err) {
console.log('Bad response format.');
callback({message: 'Bad response format. XML could not parse it'});
@ -120,20 +119,16 @@ 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();
});
WebApp.connectHandlers.use(Meteor.bindEnvironment((req, res, next) => {
middleware(req, res, next);
}));
const middleware = (req, res, next) => {
// Make sure to catch any exceptions because otherwise we'd crash
// the runner
let redirectUrl;
try {
urlParsed = url.parse(req.url, true);
const 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
@ -150,7 +145,7 @@ const middleware = (req, res, next) => {
}
const serviceUrl = Meteor.absoluteUrl(urlParsed.href.replace(/^\//g, '')).replace(/([&?])ticket=[^&]+[&]?/g, '$1').replace(/[?&]+$/g, '');
const redirectUrl = serviceUrl;//.replace(/([&?])casToken=[^&]+[&]?/g, '$1').replace(/[?&]+$/g, '');
redirectUrl = serviceUrl;//.replace(/([&?])casToken=[^&]+[&]?/g, '$1').replace(/[?&]+$/g, '');
// get auth token
const credentialToken = query.casToken;
@ -206,7 +201,7 @@ const casValidate = (req, ticket, token, service, callback) => {
* Register a server-side login handle.
* It is call after Accounts.callLoginMethod() is call from client.
*/
Accounts.registerLoginHandler((options) => {
Accounts.registerLoginHandler(async (options) => {
if (!options.cas)
return undefined;
@ -252,13 +247,13 @@ const casValidate = (req, ticket, token, service, callback) => {
if (attrs.debug) {
console.log(`CAS response : ${JSON.stringify(result)}`);
}
let user = Meteor.users.findOne({ 'username': options.username });
let user = await Meteor.users.findOneAsync({ '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);
const userId = await Accounts.insertUserDoc({}, options);
user = await Meteor.users.findOneAsync(userId);
}
if (attrs.debug) {
console.log(`Using user account ${JSON.stringify(user)}`);
@ -267,7 +262,7 @@ const casValidate = (req, ticket, token, service, callback) => {
});
const _hasCredential = (credentialToken) => {
return _.has(_casCredentialTokens, credentialToken);
return Object.prototype.hasOwnProperty.call(_casCredentialTokens, credentialToken);
}
/*

View file

@ -1,17 +1,17 @@
Package.describe({
summary: "CAS support for accounts",
version: "0.1.0",
version: "0.2.0",
name: "wekan-accounts-cas",
git: "https://github.com/wekan/meteor-accounts-cas"
});
Package.onUse(function(api) {
api.use('ecmascript');
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');

View file

@ -2,7 +2,7 @@
Package.describe({
name: 'wekan-accounts-lockout',
version: '1.0.0',
version: '1.1.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',

View file

@ -9,12 +9,12 @@ class KnownUser {
this.settings = settings;
}
startup() {
async startup() {
if (!(this.unchangedSettings instanceof Function)) {
this.updateSettings();
}
this.scheduleUnlocksForLockedAccounts();
KnownUser.unlockAccountsIfLockoutAlreadyExpired();
await this.scheduleUnlocksForLockedAccounts();
await KnownUser.unlockAccountsIfLockoutAlreadyExpired();
this.hookIntoAccounts();
}
@ -49,7 +49,7 @@ class KnownUser {
}
}
scheduleUnlocksForLockedAccounts() {
async scheduleUnlocksForLockedAccounts() {
const lockedAccountsCursor = Meteor.users.find(
{
'services.accounts-lockout.unlockTime': {
@ -63,7 +63,7 @@ class KnownUser {
},
);
const currentTime = Number(new Date());
lockedAccountsCursor.forEach((user) => {
for await (const user of lockedAccountsCursor) {
let lockDuration = KnownUser.unlockTime(user) - currentTime;
if (lockDuration >= this.settings.lockoutPeriod) {
lockDuration = this.settings.lockoutPeriod * 1000;
@ -75,10 +75,10 @@ class KnownUser {
KnownUser.unlockAccount.bind(null, user._id),
lockDuration,
);
});
}
}
static unlockAccountsIfLockoutAlreadyExpired() {
static async unlockAccountsIfLockoutAlreadyExpired() {
const currentTime = Number(new Date());
const query = {
'services.accounts-lockout.unlockTime': {
@ -91,7 +91,7 @@ class KnownUser {
'services.accounts-lockout.failedAttempts': 0,
},
};
Meteor.users.update(query, data);
await Meteor.users.updateAsync(query, data);
}
hookIntoAccounts() {
@ -100,7 +100,7 @@ class KnownUser {
}
validateLoginAttempt(loginInfo) {
async validateLoginAttempt(loginInfo) {
if (
// don't interrupt non-password logins
loginInfo.type !== 'password' ||
@ -130,12 +130,12 @@ class KnownUser {
const canReset = (currentTime - firstFailedAttempt) > (1000 * this.settings.failureWindow);
if (canReset) {
failedAttempts = 1;
KnownUser.resetAttempts(failedAttempts, userId);
await KnownUser.resetAttempts(failedAttempts, userId);
}
const canIncrement = failedAttempts < this.settings.failuresBeforeLockout;
if (canIncrement) {
KnownUser.incrementAttempts(failedAttempts, userId);
await KnownUser.incrementAttempts(failedAttempts, userId);
}
const maxAttemptsAllowed = this.settings.failuresBeforeLockout;
@ -147,7 +147,7 @@ class KnownUser {
KnownUser.tooManyAttempts(duration);
}
if (failedAttempts === maxAttemptsAllowed) {
this.setNewUnlockTime(failedAttempts, userId);
await this.setNewUnlockTime(failedAttempts, userId);
let duration = this.settings.lockoutPeriod;
duration = Math.ceil(duration);
@ -161,7 +161,7 @@ class KnownUser {
);
}
static resetAttempts(
static async resetAttempts(
failedAttempts,
userId,
) {
@ -174,10 +174,10 @@ class KnownUser {
'services.accounts-lockout.firstFailedAttempt': currentTime,
},
};
Meteor.users.update(query, data);
await Meteor.users.updateAsync(query, data);
}
static incrementAttempts(
static async incrementAttempts(
failedAttempts,
userId,
) {
@ -189,10 +189,10 @@ class KnownUser {
'services.accounts-lockout.lastFailedAttempt': currentTime,
},
};
Meteor.users.update(query, data);
await Meteor.users.updateAsync(query, data);
}
setNewUnlockTime(
async setNewUnlockTime(
failedAttempts,
userId,
) {
@ -206,14 +206,14 @@ class KnownUser {
'services.accounts-lockout.unlockTime': newUnlockTime,
},
};
Meteor.users.update(query, data);
await Meteor.users.updateAsync(query, data);
Meteor.setTimeout(
KnownUser.unlockAccount.bind(null, userId),
this.settings.lockoutPeriod * 1000,
);
}
static onLogin(loginInfo) {
static async onLogin(loginInfo) {
if (loginInfo.type !== 'password') {
return;
}
@ -225,7 +225,7 @@ class KnownUser {
'services.accounts-lockout.failedAttempts': 0,
},
};
Meteor.users.update(query, data);
await Meteor.users.updateAsync(query, data);
}
static incorrectPassword(
@ -306,7 +306,7 @@ class KnownUser {
return firstFailedAttempt || 0;
}
static unlockAccount(userId) {
static async unlockAccount(userId) {
const query = { _id: userId };
const data = {
$unset: {
@ -314,7 +314,7 @@ class KnownUser {
'services.accounts-lockout.failedAttempts': 0,
},
};
Meteor.users.update(query, data);
await Meteor.users.updateAsync(query, data);
}
}

View file

@ -13,12 +13,12 @@ class UnknownUser {
this.settings = settings;
}
startup() {
async startup() {
if (!(this.settings instanceof Function)) {
this.updateSettings();
}
this.scheduleUnlocksForLockedAccounts();
this.unlockAccountsIfLockoutAlreadyExpired();
await this.scheduleUnlocksForLockedAccounts();
await this.unlockAccountsIfLockoutAlreadyExpired();
this.hookIntoAccounts();
}
@ -53,7 +53,7 @@ class UnknownUser {
}
}
scheduleUnlocksForLockedAccounts() {
async scheduleUnlocksForLockedAccounts() {
const lockedAccountsCursor = this.AccountsLockoutCollection.find(
{
'services.accounts-lockout.unlockTime': {
@ -67,8 +67,8 @@ class UnknownUser {
},
);
const currentTime = Number(new Date());
lockedAccountsCursor.forEach((connection) => {
let lockDuration = this.unlockTime(connection) - currentTime;
for await (const connection of lockedAccountsCursor) {
let lockDuration = await this.unlockTime(connection) - currentTime;
if (lockDuration >= this.settings.lockoutPeriod) {
lockDuration = this.settings.lockoutPeriod * 1000;
}
@ -79,10 +79,10 @@ class UnknownUser {
this.unlockAccount.bind(this, connection.clientAddress),
lockDuration,
);
});
}
}
unlockAccountsIfLockoutAlreadyExpired() {
async unlockAccountsIfLockoutAlreadyExpired() {
const currentTime = Number(new Date());
const query = {
'services.accounts-lockout.unlockTime': {
@ -95,7 +95,7 @@ class UnknownUser {
'services.accounts-lockout.failedAttempts': 0,
},
};
this.AccountsLockoutCollection.update(query, data);
await this.AccountsLockoutCollection.updateAsync(query, data);
}
hookIntoAccounts() {
@ -103,7 +103,7 @@ class UnknownUser {
Accounts.onLogin(this.onLogin.bind(this));
}
validateLoginAttempt(loginInfo) {
async validateLoginAttempt(loginInfo) {
// don't interrupt non-password logins
if (
loginInfo.type !== 'password' ||
@ -120,20 +120,20 @@ class UnknownUser {
}
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 unlockTime = await this.unlockTime(loginInfo.connection);
let failedAttempts = 1 + await this.failedAttempts(loginInfo.connection);
const firstFailedAttempt = await 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);
await this.resetAttempts(failedAttempts, clientAddress);
}
const canIncrement = failedAttempts < this.settings.failuresBeforeLockout;
if (canIncrement) {
this.incrementAttempts(failedAttempts, clientAddress);
await this.incrementAttempts(failedAttempts, clientAddress);
}
const maxAttemptsAllowed = this.settings.failuresBeforeLockout;
@ -145,7 +145,7 @@ class UnknownUser {
UnknownUser.tooManyAttempts(duration);
}
if (failedAttempts === maxAttemptsAllowed) {
this.setNewUnlockTime(failedAttempts, clientAddress);
await this.setNewUnlockTime(failedAttempts, clientAddress);
let duration = this.settings.lockoutPeriod;
duration = Math.ceil(duration);
@ -159,7 +159,7 @@ class UnknownUser {
);
}
resetAttempts(
async resetAttempts(
failedAttempts,
clientAddress,
) {
@ -172,10 +172,10 @@ class UnknownUser {
'services.accounts-lockout.firstFailedAttempt': currentTime,
},
};
this.AccountsLockoutCollection.upsert(query, data);
await this.AccountsLockoutCollection.upsertAsync(query, data);
}
incrementAttempts(
async incrementAttempts(
failedAttempts,
clientAddress,
) {
@ -187,10 +187,10 @@ class UnknownUser {
'services.accounts-lockout.lastFailedAttempt': currentTime,
},
};
this.AccountsLockoutCollection.upsert(query, data);
await this.AccountsLockoutCollection.upsertAsync(query, data);
}
setNewUnlockTime(
async setNewUnlockTime(
failedAttempts,
clientAddress,
) {
@ -204,14 +204,14 @@ class UnknownUser {
'services.accounts-lockout.unlockTime': newUnlockTime,
},
};
this.AccountsLockoutCollection.upsert(query, data);
await this.AccountsLockoutCollection.upsertAsync(query, data);
Meteor.setTimeout(
this.unlockAccount.bind(this, clientAddress),
this.settings.lockoutPeriod * 1000,
);
}
onLogin(loginInfo) {
async onLogin(loginInfo) {
if (loginInfo.type !== 'password') {
return;
}
@ -223,7 +223,7 @@ class UnknownUser {
'services.accounts-lockout.failedAttempts': 0,
},
};
this.AccountsLockoutCollection.update(query, data);
await this.AccountsLockoutCollection.updateAsync(query, data);
}
static userNotFound(
@ -264,14 +264,14 @@ class UnknownUser {
return unknownUsers || false;
}
findOneByConnection(connection) {
return this.AccountsLockoutCollection.findOne({
async findOneByConnection(connection) {
return await this.AccountsLockoutCollection.findOneAsync({
clientAddress: connection.clientAddress,
});
}
unlockTime(connection) {
connection = this.findOneByConnection(connection);
async unlockTime(connection) {
connection = await this.findOneByConnection(connection);
let unlockTime;
try {
unlockTime = connection.services['accounts-lockout'].unlockTime;
@ -281,8 +281,8 @@ class UnknownUser {
return unlockTime || 0;
}
failedAttempts(connection) {
connection = this.findOneByConnection(connection);
async failedAttempts(connection) {
connection = await this.findOneByConnection(connection);
let failedAttempts;
try {
failedAttempts = connection.services['accounts-lockout'].failedAttempts;
@ -292,8 +292,8 @@ class UnknownUser {
return failedAttempts || 0;
}
lastFailedAttempt(connection) {
connection = this.findOneByConnection(connection);
async lastFailedAttempt(connection) {
connection = await this.findOneByConnection(connection);
let lastFailedAttempt;
try {
lastFailedAttempt = connection.services['accounts-lockout'].lastFailedAttempt;
@ -303,8 +303,8 @@ class UnknownUser {
return lastFailedAttempt || 0;
}
firstFailedAttempt(connection) {
connection = this.findOneByConnection(connection);
async firstFailedAttempt(connection) {
connection = await this.findOneByConnection(connection);
let firstFailedAttempt;
try {
firstFailedAttempt = connection.services['accounts-lockout'].firstFailedAttempt;
@ -314,7 +314,7 @@ class UnknownUser {
return firstFailedAttempt || 0;
}
unlockAccount(clientAddress) {
async unlockAccount(clientAddress) {
const query = { clientAddress };
const data = {
$unset: {
@ -322,7 +322,7 @@ class UnknownUser {
'services.accounts-lockout.failedAttempts': 0,
},
};
this.AccountsLockoutCollection.update(query, data);
await this.AccountsLockoutCollection.updateAsync(query, data);
}
}

View file

@ -86,7 +86,7 @@ function loginWithSandstorm(connection, apiHost, apiToken) {
var sendXhr = function () {
if (!waiting) return; // Method call finished.
headers = {"Content-Type": "application/x-sandstorm-login-token"};
var headers = {"Content-Type": "application/x-sandstorm-login-token"};
var testInfo = localStorage.sandstormTestUserInfo;
if (testInfo) {
@ -120,16 +120,20 @@ function loginWithSandstorm(connection, apiHost, apiToken) {
// Send the token in an HTTP POST request which on the server side will allow us to receive the
// Sandstorm headers.
HTTP.post(postUrl,
{content: token, headers: headers},
function (error, result) {
if (error) {
console.error("couldn't get /.sandstorm-login:", error);
fetch(postUrl, {
method: 'POST',
headers: headers,
body: token
}).then(function (response) {
if (!response.ok) {
throw new Error(response.statusText);
}
}).catch(function (error) {
console.error("couldn't get /.sandstorm-login:", error);
if (waiting) {
// Try again in a second.
Meteor.setTimeout(sendXhr, 1000);
}
if (waiting) {
// Try again in a second.
Meteor.setTimeout(sendXhr, 1000);
}
});
};

View file

@ -21,7 +21,7 @@
Package.describe({
summary: "Login service for Sandstorm.io applications",
version: "0.8.0",
version: "0.9.0",
name: "wekan-accounts-sandstorm",
git: "https://github.com/sandstorm-io/meteor-accounts-sandstorm.git"
});
@ -30,7 +30,7 @@ Package.onUse(function(api) {
api.use('random', ['client', 'server']);
api.use('accounts-base', ['client', 'server'], {weak: true});
api.use('webapp', 'server');
api.use('http', 'client');
api.use('fetch', 'client');
api.use('tracker', 'client');
api.use('reactive-var', 'client');
api.use('check', 'server');

View file

@ -43,12 +43,6 @@ if (__meteor_runtime_config__.SANDSTORM) {
});
}
var Future = Npm.require("fibers/future");
var inMeteor = Meteor.bindEnvironment(function (callback) {
callback();
});
var logins = {};
// Maps tokens to currently-waiting login method calls.
@ -83,22 +77,24 @@ if (__meteor_runtime_config__.SANDSTORM) {
});
Meteor.methods({
loginWithSandstorm: function (token) {
async loginWithSandstorm(token) {
check(token, String);
var future = new Future();
const loginPromise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Meteor.Error("timeout", "Gave up waiting for login rendezvous XHR."));
}, 10000);
logins[token] = future;
var timeout = setTimeout(function () {
future.throw(new Meteor.Error("timeout", "Gave up waiting for login rendezvous XHR."));
}, 10000);
logins[token] = { resolve, reject, timeout };
});
var info;
try {
info = future.wait();
info = await loginPromise;
} finally {
clearTimeout(timeout);
if (logins[token] && logins[token].timeout) {
clearTimeout(logins[token].timeout);
}
delete logins[token];
}
@ -128,85 +124,79 @@ if (__meteor_runtime_config__.SANDSTORM) {
return next();
});
function readAll(stream) {
var future = new Future();
var chunks = [];
stream.on("data", function (chunk) {
chunks.push(chunk.toString());
async function readAll(stream) {
return new Promise((resolve, reject) => {
const chunks = [];
stream.on("data", function (chunk) {
chunks.push(chunk.toString());
});
stream.on("error", function (err) {
reject(err);
});
stream.on("end", function () {
resolve(chunks.join(""));
});
});
stream.on("error", function (err) {
future.throw(err);
});
stream.on("end", function () {
future.return();
});
future.wait();
return chunks.join("");
}
var handlePostToken = Meteor.bindEnvironment(function (req, res) {
inMeteor(function () {
try {
// Note that cross-origin POSTs cannot set arbitrary Content-Types without explicit CORS
// permission, so this effectively prevents XSRF.
if (req.headers["content-type"].split(";")[0].trim() !== "application/x-sandstorm-login-token") {
throw new Error("wrong Content-Type for .sandstorm-login: " + req.headers["content-type"]);
}
var token = readAll(req);
var future = logins[token];
if (!future) {
throw new Error("no current login request matching token");
}
var permissions = req.headers["x-sandstorm-permissions"];
if (permissions && permissions !== "") {
permissions = permissions.split(",");
} else {
permissions = [];
}
var sandstormInfo = {
id: req.headers["x-sandstorm-user-id"] || null,
name: decodeURIComponent(req.headers["x-sandstorm-username"]),
permissions: permissions,
picture: req.headers["x-sandstorm-user-picture"] || null,
preferredHandle: req.headers["x-sandstorm-preferred-handle"] || null,
pronouns: req.headers["x-sandstorm-user-pronouns"] || null,
};
var userInfo = {sandstorm: sandstormInfo};
if (Package["accounts-base"]) {
if (sandstormInfo.id) {
// The user is logged into Sandstorm. Create a Meteor account for them, or find the
// existing one, and record the user ID.
var login = Package["accounts-base"].Accounts.updateOrCreateUserFromExternalService(
"sandstorm", sandstormInfo, {profile: {name: sandstormInfo.name}});
userInfo.userId = login.userId;
} else {
userInfo.userId = null;
}
} else {
// Since the app isn't using regular Meteor accounts, we can define Meteor.userId()
// however we want.
userInfo.userId = sandstormInfo.id;
}
userInfo.sessionId = req.headers["x-sandstorm-session-id"] || null;
userInfo.tabId = req.headers["x-sandstorm-tab-id"] || null;
future.return(userInfo);
res.writeHead(204, {});
res.end();
} catch (err) {
res.writeHead(500, {
"Content-Type": "text/plain"
});
res.end(err.stack);
var handlePostToken = Meteor.bindEnvironment(async function (req, res) {
try {
// Note that cross-origin POSTs cannot set arbitrary Content-Types without explicit CORS
// permission, so this effectively prevents XSRF.
if (req.headers["content-type"].split(";")[0].trim() !== "application/x-sandstorm-login-token") {
throw new Error("wrong Content-Type for .sandstorm-login: " + req.headers["content-type"]);
}
});
var token = await readAll(req);
var loginEntry = logins[token];
if (!loginEntry) {
throw new Error("no current login request matching token");
}
var permissions = req.headers["x-sandstorm-permissions"];
if (permissions && permissions !== "") {
permissions = permissions.split(",");
} else {
permissions = [];
}
var sandstormInfo = {
id: req.headers["x-sandstorm-user-id"] || null,
name: decodeURIComponent(req.headers["x-sandstorm-username"]),
permissions: permissions,
picture: req.headers["x-sandstorm-user-picture"] || null,
preferredHandle: req.headers["x-sandstorm-preferred-handle"] || null,
pronouns: req.headers["x-sandstorm-user-pronouns"] || null,
};
var userInfo = {sandstorm: sandstormInfo};
if (Package["accounts-base"]) {
if (sandstormInfo.id) {
// The user is logged into Sandstorm. Create a Meteor account for them, or find the
// existing one, and record the user ID.
var login = await Package["accounts-base"].Accounts.updateOrCreateUserFromExternalService(
"sandstorm", sandstormInfo, {profile: {name: sandstormInfo.name}});
userInfo.userId = login.userId;
} else {
userInfo.userId = null;
}
} else {
// Since the app isn't using regular Meteor accounts, we can define Meteor.userId()
// however we want.
userInfo.userId = sandstormInfo.id;
}
userInfo.sessionId = req.headers["x-sandstorm-session-id"] || null;
userInfo.tabId = req.headers["x-sandstorm-tab-id"] || null;
loginEntry.resolve(userInfo);
res.writeHead(204, {});
res.end();
} catch (err) {
res.writeHead(500, {
"Content-Type": "text/plain"
});
res.end(err.stack);
}
});
}

View file

@ -467,7 +467,7 @@ temporary rendered events).
/* resizer (touch devices) */
.fc-h-event.fc-selected .fc-resizer {
/* 8x8 little dot */
border-radius: 4px;
border-radius: 0.4ch;
border-width: 1px;
width: 6px;
height: 6px;
@ -1145,7 +1145,7 @@ be a descendant of the grid when it is being dragged.
height: 8px;
overflow: hidden;
line-height: 8px;
font-size: 11px;
font-family: monospace;
text-align: center;
cursor: s-resize; }

View file

@ -1,11 +1,11 @@
// creates Object if not present in collection
// initArr = [displayName, shortName, website, isActive]
// objString = ["Org","Team"] for method mapping
function createObject(initArr, objString)
async function createObject(initArr, objString)
{
functionName = objString === "Org" ? 'setCreateOrgFromOidc' : 'setCreateTeamFromOidc';
creationString = 'setCreate'+ objString + 'FromOidc';
return Meteor.call(functionName,
return await Meteor.callAsync(functionName,
initArr[0],//displayName
initArr[1],//desc
initArr[2],//shortName
@ -13,10 +13,10 @@ function createObject(initArr, objString)
initArr[4]//xxxisActive
);
}
function updateObject(initArr, objString)
async function updateObject(initArr, objString)
{
functionName = objString === "Org" ? 'setOrgAllFieldsFromOidc' : 'setTeamAllFieldsFromOidc';
return Meteor.call(functionName,
return await Meteor.callAsync(functionName,
initArr[0],//team || org Object
initArr[1],//displayName
initArr[2],//desc
@ -57,7 +57,7 @@ module.exports = {
// isAdmin: [true, false] -> admin group becomes admin in wekan
// isOrganization: [true, false] -> creates org and adds to user
// displayName: "string"
addGroupsWithAttributes: function (user, groups){
addGroupsWithAttributes: async function (user, groups){
teamArray=[];
orgArray=[];
isAdmin = [];
@ -76,20 +76,20 @@ addGroupsWithAttributes: function (user, groups){
isAdmin.push(group.isAdmin || false);
if (isOrg)
{
org = Org.findOne({"orgDisplayName": group.displayName});
org = await Org.findOneAsync({"orgDisplayName": group.displayName});
if(org)
{
if(contains(orgs, org, "org"))
{
initAttributes.unshift(org);
updateObject(initAttributes, "Org");
await updateObject(initAttributes, "Org");
continue;
}
}
else if(forceCreate)
{
createObject(initAttributes, "Org");
org = Org.findOne({'orgDisplayName': group.displayName});
await createObject(initAttributes, "Org");
org = await Org.findOneAsync({'orgDisplayName': group.displayName});
}
else
{
@ -102,20 +102,20 @@ addGroupsWithAttributes: function (user, groups){
else
{
//start team routine
team = Team.findOne({"teamDisplayName": group.displayName});
team = await Team.findOneAsync({"teamDisplayName": group.displayName});
if (team)
{
if(contains(teams, team, "team"))
{
initAttributes.unshift(team);
updateObject(initAttributes, "Team");
await updateObject(initAttributes, "Team");
continue;
}
}
else if(forceCreate)
{
createObject(initAttributes, "Team");
team = Team.findOne({'teamDisplayName': group.displayName});
await createObject(initAttributes, "Team");
team = await Team.findOneAsync({'teamDisplayName': group.displayName});
}
else
{
@ -129,28 +129,28 @@ addGroupsWithAttributes: function (user, groups){
// 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))}});
await users.updateAsync({ _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});
await users.updateAsync({ _id: user._id }, { $push: teams});
await users.updateAsync({ _id: user._id }, { $push: orgs});
// remove temporary oidc data from user collection
users.update({ _id: user._id }, { $unset: {"services.oidc.groups": []}});
await users.updateAsync({ _id: user._id }, { $unset: {"services.oidc.groups": []}});
return;
},
changeUsername: function(user, name)
changeUsername: async function(user, name)
{
username = {'username': name};
if (user.username != username) users.update({ _id: user._id }, { $set: username});
if (user.username != username) await users.updateAsync({ _id: user._id }, { $set: username});
},
changeFullname: function(user, name)
changeFullname: async function(user, name)
{
username = {'profile.fullname': name};
if (user.username != username) users.update({ _id: user._id }, { $set: username});
if (user.username != username) await users.updateAsync({ _id: user._id }, { $set: username});
},
addEmail: function(user, email)
addEmail: async function(user, email)
{
user_email = user.emails || [];
var contained = false;
@ -173,7 +173,7 @@ addEmail: function(user, email)
{
user_email.unshift({'address': email, 'verified': true});
user_email = {'emails': user_email};
users.update({ _id: user._id }, { $set: user_email});
await users.updateAsync({ _id: user._id }, { $set: user_email});
}
}
}

View file

@ -1,11 +1,15 @@
import {addGroupsWithAttributes, addEmail, changeFullname, changeUsername} from './loginHandler';
import { fetch, Headers } from 'meteor/fetch';
import { URLSearchParams } from 'meteor/url';
import { Buffer } from 'node:buffer';
import https from 'https';
import fs from 'fs';
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);
}
@ -18,10 +22,10 @@ var profile = {};
var serviceData = {};
var userinfo = {};
OAuth.registerService('oidc', 2, null, function (query) {
OAuth.registerService('oidc', 2, null, async function (query) {
var debug = process.env.DEBUG === 'true';
var token = getToken(query);
var token = await getToken(query);
if (debug) console.log('XXX: register token:', token);
var accessToken = token.access_token || token.id_token;
@ -40,7 +44,7 @@ OAuth.registerService('oidc', 2, null, function (query) {
else
{
// normal behaviour, getting the claims from UserInfo endpoint.
userinfo = getUserInfo(accessToken);
userinfo = await getUserInfo(accessToken);
}
if (userinfo.ocs) userinfo = userinfo.ocs.data; // Nextcloud hack
@ -73,7 +77,8 @@ OAuth.registerService('oidc', 2, null, function (query) {
if (accessToken) {
var tokenContent = getTokenContent(accessToken);
var fields = _.pick(tokenContent, getConfiguration().idTokenWhitelistFields);
var config = await getConfiguration();
var fields = _.pick(tokenContent, config.idTokenWhitelistFields);
_.extend(serviceData, fields);
}
@ -100,7 +105,7 @@ OAuth.registerService('oidc', 2, null, function (query) {
// 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});
user = await Meteor.users.findOneAsync({'_id': serviceData.id});
serviceData.groups.forEach(function(groupName, i)
{
@ -119,8 +124,8 @@ OAuth.registerService('oidc', 2, null, function (query) {
// Fix OIDC login loop for integer user ID. Thanks to danielkaiser.
// https://github.com/wekan/wekan/issues/4795
Meteor.call('groupRoutineOnLogin',serviceData, ""+serviceData.id);
Meteor.call('boardRoutineOnLogin',serviceData, ""+serviceData.id);
await Meteor.callAsync('groupRoutineOnLogin',serviceData, ""+serviceData.id);
await Meteor.callAsync('boardRoutineOnLogin',serviceData, ""+serviceData.id);
return {
serviceData: serviceData,
@ -134,143 +139,166 @@ if (Meteor.release) {
}
if (process.env.ORACLE_OIM_ENABLED !== 'true' && process.env.ORACLE_OIM_ENABLED !== true) {
var getToken = function (query) {
var getToken = async function (query) {
var debug = process.env.DEBUG === 'true';
var config = getConfiguration();
var config = await getConfiguration();
var serverTokenEndpoint;
if(config.tokenEndpoint.includes('https://')){
var serverTokenEndpoint = config.tokenEndpoint;
serverTokenEndpoint = config.tokenEndpoint;
}else{
var serverTokenEndpoint = config.serverUrl + config.tokenEndpoint;
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
}
};
var body = new URLSearchParams({
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
});
var fetchOptions = {
method: 'POST',
headers: new Headers({
'Accept': 'application/json',
'User-Agent': userAgent,
'Content-Type': 'application/x-www-form-urlencoded'
}),
body: body.toString()
};
if (httpCa) {
postOptions['npmRequestOptions'] = { ca: httpCa };
fetchOptions.agent = new https.Agent({ ca: httpCa });
}
response = HTTP.post(serverTokenEndpoint, postOptions);
var response = await fetch(serverTokenEndpoint, fetchOptions);
var data = await response.json();
if (!response.ok) {
throw new Error("Failed to get token from OIDC " + serverTokenEndpoint + ": " + response.statusText);
}
if (data.error) {
// if the http response was a json object with an error attribute
throw new Error("Failed to complete handshake with OIDC " + serverTokenEndpoint + ": " + data.error);
}
if (debug) console.log('XXX: getToken response: ', data);
return data;
} 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 getToken = async function (query) {
var debug = process.env.DEBUG === 'true';
var config = getConfiguration();
var config = await getConfiguration();
var serverTokenEndpoint;
if(config.tokenEndpoint.includes('https://')){
var serverTokenEndpoint = config.tokenEndpoint;
serverTokenEndpoint = config.tokenEndpoint;
}else{
var serverTokenEndpoint = config.serverUrl + config.tokenEndpoint;
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');
var dataToken = process.env.OAUTH2_CLIENT_ID + ':' + process.env.OAUTH2_SECRET;
var strBasicToken64 = Buffer.from(dataToken).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
}
};
var body = new URLSearchParams({
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
});
var fetchOptions = {
method: 'POST',
headers: new Headers({
'Accept': 'application/json',
'User-Agent': userAgent,
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + strBasicToken64
}),
body: body.toString()
};
if (httpCa) {
postOptions['npmRequestOptions'] = { ca: httpCa };
fetchOptions.agent = new https.Agent({ ca: httpCa });
}
response = HTTP.post(serverTokenEndpoint, postOptions);
var response = await fetch(serverTokenEndpoint, fetchOptions);
var data = await response.json();
if (!response.ok) {
throw new Error("Failed to get token from OIDC " + serverTokenEndpoint + ": " + response.statusText);
}
if (data.error) {
// if the http response was a json object with an error attribute
throw new Error("Failed to complete handshake with OIDC " + serverTokenEndpoint + ": " + data.error);
}
// eslint-disable-next-line no-console
if (debug) console.log('XXX: getToken response: ', data);
return data;
} 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 getUserInfo = async function (accessToken) {
var debug = process.env.DEBUG === 'true';
var config = getConfiguration();
var config = await 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.
var serverUserinfoEndpoint;
if (config.userinfoEndpoint.includes("https://")) {
var serverUserinfoEndpoint = config.userinfoEndpoint;
serverUserinfoEndpoint = config.userinfoEndpoint;
} else {
var serverUserinfoEndpoint = config.serverUrl + config.userinfoEndpoint;
serverUserinfoEndpoint = config.serverUrl + config.userinfoEndpoint;
}
var response;
try {
var getOptions = {
headers: {
"User-Agent": userAgent,
"Authorization": "Bearer " + accessToken
}
};
var fetchOptions = {
method: 'GET',
headers: new Headers({
'User-Agent': userAgent,
'Authorization': 'Bearer ' + accessToken
})
};
if (httpCa) {
getOptions['npmRequestOptions'] = { ca: httpCa };
fetchOptions.agent = new https.Agent({ ca: httpCa });
}
response = HTTP.get(serverUserinfoEndpoint, getOptions);
var response = await fetch(serverUserinfoEndpoint, fetchOptions);
if (!response.ok) {
throw new Error("Failed to fetch userinfo from OIDC " + serverUserinfoEndpoint + ": " + response.statusText);
}
var data = await response.json();
if (debug) console.log('XXX: getUserInfo response: ', data);
return data;
} 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' });
var getConfiguration = async function () {
var config = await ServiceConfiguration.configurations.findOneAsync({ service: 'oidc' });
if (!config) {
throw new ServiceConfiguration.ConfigError('Service oidc not configured.');
}
@ -295,24 +323,24 @@ var getTokenContent = function (token) {
return content;
}
Meteor.methods({
'groupRoutineOnLogin': function(info, userId)
'groupRoutineOnLogin': async 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({'services.oidc.id': userId});
user = await users.findOneAsync({'services.oidc.id': userId});
if(user) {
//updates/creates Groups and user admin privileges accordingly if not undefined
if (info.groups) {
addGroupsWithAttributes(user, info.groups);
await 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);
if(info.email) await addEmail(user, info.email);
if(info.fullname) await changeFullname(user, info.fullname);
if(info.username) await changeUsername(user, info.username);
}
}
}
@ -328,8 +356,9 @@ Meteor.methods({
const defaultBoardId = defaultBoardParams.shift()
if (!defaultBoardId) return
const board = Boards.findOne(defaultBoardId)
const userId = Users.findOne({ 'services.oidc.id': oidcUserId })?._id
const board = await Boards.findOneAsync(defaultBoardId)
const user = await Users.findOneAsync({ 'services.oidc.id': oidcUserId })
const userId = user?._id
const memberIndex = _.pluck(board?.members, 'userId').indexOf(userId);
if(!board || !userId || memberIndex > -1) return

View file

@ -1,6 +1,6 @@
Package.describe({
summary: "OpenID Connect (OIDC) flow for Meteor",
version: "1.0.12",
version: "1.1.0",
name: "wekan-oidc",
git: "https://github.com/wekan/wekan-oidc.git",
});
@ -8,7 +8,7 @@ Package.describe({
Package.onUse(function(api) {
api.use('oauth2', ['client', 'server']);
api.use('oauth', ['client', 'server']);
api.use('http', ['server']);
api.use('fetch', ['server']);
api.use('underscore', 'client');
api.use('ecmascript');
api.use('templating', 'client');