You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
424 lines
12 KiB
424 lines
12 KiB
import type { IUser } from '@rocket.chat/core-typings';
|
|
import { cronJobs } from '@rocket.chat/cron';
|
|
import { Users } from '@rocket.chat/models';
|
|
import { SHA256 } from '@rocket.chat/sha256';
|
|
import AtlassianCrowd from 'atlassian-crowd-patched';
|
|
import { Accounts } from 'meteor/accounts-base';
|
|
import { Meteor } from 'meteor/meteor';
|
|
|
|
import { logger } from './logger';
|
|
import { crowdIntervalValuesToCronMap } from '../../../server/settings/crowd';
|
|
import { deleteUser } from '../../lib/server/functions/deleteUser';
|
|
import { _setRealName } from '../../lib/server/functions/setRealName';
|
|
import { setUserActiveStatus } from '../../lib/server/functions/setUserActiveStatus';
|
|
import { notifyOnUserChange, notifyOnUserChangeById, notifyOnUserChangeAsync } from '../../lib/server/lib/notifyListener';
|
|
import { settings } from '../../settings/server';
|
|
|
|
type CrowdUser = Pick<IUser, '_id' | 'username'> & { crowd: Record<string, any>; crowd_username: string };
|
|
|
|
function fallbackDefaultAccountSystem(bind: typeof Accounts, username: string | Record<string, any>, password: string) {
|
|
if (typeof username === 'string') {
|
|
if (username.indexOf('@') === -1) {
|
|
username = { username };
|
|
} else {
|
|
username = { email: username };
|
|
}
|
|
}
|
|
|
|
logger.info({ msg: 'Fallback to default account system', username });
|
|
|
|
const loginRequest = {
|
|
user: username,
|
|
password: {
|
|
digest: SHA256(password),
|
|
algorithm: 'sha-256',
|
|
},
|
|
};
|
|
|
|
return Accounts._runLoginHandlers(bind, loginRequest);
|
|
}
|
|
|
|
export class CROWD {
|
|
private crowdClient: any;
|
|
|
|
private options: {
|
|
crowd: {
|
|
base: string;
|
|
};
|
|
application: {
|
|
name: string;
|
|
password: string;
|
|
};
|
|
rejectUnauthorized: boolean;
|
|
};
|
|
|
|
constructor() {
|
|
let url = settings.get<string>('CROWD_URL');
|
|
|
|
this.options = {
|
|
crowd: {
|
|
base: !/\/$/.test(url) ? (url += '/') : url,
|
|
},
|
|
application: {
|
|
name: settings.get('CROWD_APP_USERNAME'),
|
|
password: settings.get('CROWD_APP_PASSWORD'),
|
|
},
|
|
rejectUnauthorized: settings.get('CROWD_Reject_Unauthorized'),
|
|
};
|
|
|
|
this.crowdClient = new AtlassianCrowd(this.options);
|
|
}
|
|
|
|
async checkConnection(): Promise<void> {
|
|
return new Promise((resolve, reject) =>
|
|
this.crowdClient.ping((err: any) => {
|
|
if (err) {
|
|
reject(err);
|
|
}
|
|
resolve();
|
|
}),
|
|
);
|
|
}
|
|
|
|
async fetchCrowdUser(crowdUsername: string): Promise<Record<string, any>> {
|
|
return new Promise((resolve, reject) =>
|
|
this.crowdClient.user.find(crowdUsername, (err: any, userResponse: Record<string, any>) => {
|
|
if (err) {
|
|
reject(err);
|
|
}
|
|
resolve({
|
|
displayname: userResponse['display-name'],
|
|
username: userResponse.name,
|
|
email: userResponse.email,
|
|
active: userResponse.active,
|
|
crowd_username: crowdUsername,
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
|
|
async searchForCrowdUserByMail(email?: string): Promise<Record<string, any> | undefined> {
|
|
return new Promise((resolve) =>
|
|
this.crowdClient.search('user', `email=" ${email} "`, (err: any, response: Record<string, any>) => {
|
|
if (err) {
|
|
resolve(undefined);
|
|
}
|
|
resolve(response);
|
|
}),
|
|
);
|
|
}
|
|
|
|
async authenticate(username: string, password: string): Promise<Record<string, any> | undefined> {
|
|
if (!username || !password) {
|
|
logger.error('No username or password');
|
|
return;
|
|
}
|
|
const projection = { username: 1, crowd_username: 1, crowd: 1 };
|
|
logger.info('Extracting crowd_username');
|
|
let user = null;
|
|
let crowdUsername = username;
|
|
|
|
if (username.indexOf('@') !== -1) {
|
|
const email = username;
|
|
|
|
user = await Users.findOne<CrowdUser>({ 'emails.address': email }, { projection });
|
|
if (user) {
|
|
crowdUsername = user.crowd_username;
|
|
} else {
|
|
logger.debug({ msg: 'Could not find a user by email', username });
|
|
}
|
|
}
|
|
|
|
if (user == null) {
|
|
user = await Users.findOne<CrowdUser>({ username }, { projection });
|
|
if (user) {
|
|
crowdUsername = user.crowd_username;
|
|
} else {
|
|
logger.debug('Could not find a user by username');
|
|
}
|
|
}
|
|
|
|
if (user == null) {
|
|
user = await Users.findOne<CrowdUser>({ crowd_username: username }, { projection });
|
|
if (user) {
|
|
crowdUsername = user.crowd_username;
|
|
} else {
|
|
logger.debug({ msg: 'Could not find a user with by crowd_username', username });
|
|
}
|
|
}
|
|
|
|
if (user && !crowdUsername) {
|
|
logger.debug('Local user found, redirecting to fallback login');
|
|
return {
|
|
crowd: false,
|
|
};
|
|
}
|
|
|
|
if (!user && crowdUsername) {
|
|
logger.debug('New user. User is not synced yet.');
|
|
}
|
|
logger.debug({ msg: 'Going to crowd', crowdUsername });
|
|
|
|
return new Promise((resolve, reject) =>
|
|
this.crowdClient.user.authenticate(crowdUsername, password, async (err: any, res: Record<string, any>) => {
|
|
if (err) {
|
|
reject(err);
|
|
}
|
|
const user = res;
|
|
try {
|
|
const crowdUser: Record<string, any> = await this.fetchCrowdUser(crowdUsername);
|
|
if (user && settings.get('CROWD_Allow_Custom_Username') === true) {
|
|
crowdUser.username = user.name;
|
|
}
|
|
|
|
if (user) {
|
|
crowdUser._id = user._id;
|
|
}
|
|
crowdUser.password = password;
|
|
|
|
resolve(crowdUser);
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
async syncDataToUser(crowdUser: Record<string, any>, id: string) {
|
|
const user = {
|
|
username: this.cleanUsername(crowdUser.username),
|
|
crowd_username: crowdUser.crowd_username,
|
|
emails: [
|
|
{
|
|
address: crowdUser.email,
|
|
verified: settings.get<boolean>('Accounts_Verify_Email_For_External_Accounts'),
|
|
},
|
|
],
|
|
crowd: true,
|
|
};
|
|
|
|
if (crowdUser.password) {
|
|
await Accounts.setPasswordAsync(id, crowdUser.password, {
|
|
logout: false,
|
|
});
|
|
|
|
await Users.unsetRequirePasswordChange(id);
|
|
}
|
|
|
|
if (crowdUser.displayname) {
|
|
await _setRealName(id, crowdUser.displayname);
|
|
}
|
|
|
|
await Users.updateOne(
|
|
{ _id: id },
|
|
{
|
|
$set: user,
|
|
},
|
|
);
|
|
|
|
void notifyOnUserChange({
|
|
clientAction: 'updated',
|
|
id,
|
|
diff: {
|
|
...user,
|
|
...(crowdUser.displayname && { name: crowdUser.displayname }),
|
|
},
|
|
});
|
|
|
|
await setUserActiveStatus(id, crowdUser.active);
|
|
}
|
|
|
|
async sync() {
|
|
// if crowd is disabled bail out
|
|
if (settings.get('CROWD_Enable') !== true) {
|
|
return;
|
|
}
|
|
|
|
const users =
|
|
((await Users.findCrowdUsers().toArray()) as unknown as (IUser & { crowd: Record<string, any>; crowd_username: string })[]) || [];
|
|
|
|
logger.info('Sync started...');
|
|
|
|
for await (const user of users) {
|
|
let crowdUsername = user.hasOwnProperty('crowd_username') ? user.crowd_username : user.username;
|
|
logger.info({ msg: 'Syncing user', crowdUsername });
|
|
if (!crowdUsername) {
|
|
logger.warn({ msg: 'User has no crowd_username', username: user.username });
|
|
continue;
|
|
}
|
|
|
|
let crowdUser = null;
|
|
|
|
try {
|
|
crowdUser = await this.fetchCrowdUser(crowdUsername);
|
|
} catch (err) {
|
|
logger.debug({ msg: 'Error while syncing user from CROWD', err });
|
|
logger.error({ msg: 'Could not sync user with username', crowd_username: crowdUsername });
|
|
|
|
const email = user.emails?.[0].address;
|
|
logger.info({ msg: 'Attempting to find user by email', email });
|
|
|
|
const response = await this.searchForCrowdUserByMail(email);
|
|
if (!response || response.users.length === 0) {
|
|
logger.warn({ msg: 'Could not find user in CROWD with username or email', crowd_username: crowdUsername, email });
|
|
if (settings.get('CROWD_Remove_Orphaned_Users') === true) {
|
|
logger.info({ msg: 'Removing user', crowd_username: crowdUsername });
|
|
setImmediate(async () => {
|
|
await deleteUser(user._id);
|
|
logger.info({ msg: 'User removed', crowd_username: crowdUsername });
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
crowdUsername = response.users[0].name;
|
|
logger.info({ msg: 'User found by email. Syncing user', crowd_username: crowdUsername });
|
|
if (!crowdUsername) {
|
|
logger.warn({ msg: 'User has no crowd_username', username: user.username });
|
|
continue;
|
|
}
|
|
|
|
crowdUser = await this.fetchCrowdUser(crowdUsername);
|
|
}
|
|
|
|
if (settings.get('CROWD_Allow_Custom_Username') === true) {
|
|
crowdUser.username = user.username;
|
|
}
|
|
|
|
await this.syncDataToUser(crowdUser, user._id);
|
|
}
|
|
}
|
|
|
|
cleanUsername(username: string) {
|
|
if (settings.get('CROWD_Clean_Usernames') === true) {
|
|
return username.split('@')[0];
|
|
}
|
|
return username;
|
|
}
|
|
|
|
async updateUserCollection(crowdUser: Record<string, any>) {
|
|
const username = crowdUser.crowd_username || crowdUser.username;
|
|
const mail = crowdUser.email;
|
|
|
|
// If id is not provided, user is linked by crowd_username or email address
|
|
const userQuery = {
|
|
...(crowdUser._id && { _id: crowdUser._id }),
|
|
...(!crowdUser._id && {
|
|
$or: [{ crowd_username: username }, { 'emails.address': mail }],
|
|
}),
|
|
};
|
|
|
|
// find our existing user if they exist
|
|
const user = await Users.findOne(userQuery);
|
|
|
|
if (user) {
|
|
const stampedToken = Accounts._generateStampedLoginToken();
|
|
|
|
await Users.updateOne(
|
|
{ _id: user._id },
|
|
{
|
|
$push: {
|
|
'services.resume.loginTokens': Accounts._hashStampedToken(stampedToken),
|
|
},
|
|
},
|
|
);
|
|
|
|
// TODO this can be optmized so places that care about loginTokens being removed are invoked directly
|
|
// instead of having to listen to every watch.users event
|
|
void notifyOnUserChangeAsync(async () => {
|
|
const userTokens = await Users.findOneById(crowdUser._id, { projection: { 'services.resume.loginTokens': 1 } });
|
|
if (!userTokens) {
|
|
return;
|
|
}
|
|
|
|
return {
|
|
clientAction: 'updated',
|
|
id: crowdUser._id,
|
|
diff: { 'services.resume.loginTokens': userTokens.services?.resume?.loginTokens },
|
|
};
|
|
});
|
|
|
|
await this.syncDataToUser(crowdUser, user._id);
|
|
|
|
return {
|
|
userId: user._id,
|
|
token: stampedToken.token,
|
|
};
|
|
}
|
|
|
|
// Attempt to create the new user
|
|
try {
|
|
crowdUser._id = await Accounts.createUserAsync(crowdUser);
|
|
|
|
void notifyOnUserChangeById({ clientAction: 'inserted', id: crowdUser._id });
|
|
|
|
// sync the user data
|
|
await this.syncDataToUser(crowdUser, crowdUser._id);
|
|
|
|
return {
|
|
userId: crowdUser._id,
|
|
};
|
|
} catch (err) {
|
|
logger.error({ msg: 'Error creating new crowd user.', err });
|
|
}
|
|
}
|
|
}
|
|
|
|
Accounts.registerLoginHandler('crowd', async function (this: typeof Accounts, loginRequest) {
|
|
if (!loginRequest.crowd) {
|
|
return undefined;
|
|
}
|
|
|
|
logger.info({ msg: 'Init CROWD login', username: loginRequest.username });
|
|
|
|
if (settings.get('CROWD_Enable') !== true) {
|
|
return fallbackDefaultAccountSystem(this, loginRequest.username, loginRequest.crowdPassword);
|
|
}
|
|
|
|
try {
|
|
const crowd = new CROWD();
|
|
const user = await crowd.authenticate(loginRequest.username, loginRequest.crowdPassword);
|
|
|
|
if (user && user.crowd === false) {
|
|
logger.debug({ msg: 'User is not a valid crowd user, falling back', username: loginRequest.username });
|
|
return fallbackDefaultAccountSystem(this, loginRequest.username, loginRequest.crowdPassword);
|
|
}
|
|
|
|
if (!user) {
|
|
logger.debug({ msg: 'User is not allowed to access Rocket.Chat', username: loginRequest.username });
|
|
return new Meteor.Error('not-authorized', 'User is not authorized by crowd');
|
|
}
|
|
|
|
const result = await crowd.updateUserCollection(user);
|
|
|
|
return result;
|
|
} catch (err: any) {
|
|
logger.debug({ err });
|
|
logger.error('Crowd user not authenticated due to an error');
|
|
throw new Meteor.Error('user-not-found', err.message);
|
|
}
|
|
});
|
|
|
|
const jobName = 'CROWD_Sync';
|
|
|
|
Meteor.startup(() => {
|
|
settings.watchMultiple(['CROWD_Sync_User_Data', 'CROWD_Sync_Interval'], async function addCronJobDebounced([data, interval]) {
|
|
if (data !== true) {
|
|
logger.info('Disabling CROWD Background Sync');
|
|
if (await cronJobs.has(jobName)) {
|
|
await cronJobs.remove(jobName);
|
|
}
|
|
return;
|
|
}
|
|
const crowd = new CROWD();
|
|
if (interval) {
|
|
if (await cronJobs.has(jobName)) {
|
|
await cronJobs.remove(jobName);
|
|
}
|
|
|
|
logger.info('Enabling CROWD Background Sync');
|
|
const cronInterval = crowdIntervalValuesToCronMap[String(interval)];
|
|
|
|
await cronJobs.add(jobName, cronInterval, () => crowd.sync());
|
|
}
|
|
});
|
|
});
|
|
|