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 { settings } from '../../../settings'; import { crowdIntervalValuesToCronMap } from '../../../settings/crowd'; import { notifyOnUserChange, notifyOnUserChangeById, notifyOnUserChangeAsync } from '../../notifyListener'; import { deleteUser } from '../../users/deleteUser'; import { setRealName } from '../../users/setRealName'; import { setUserActiveStatus } from '../../users/setUserActiveStatus'; type CrowdUser = Pick & { crowd: Record; crowd_username: string }; function fallbackDefaultAccountSystem(bind: typeof Accounts, username: string | Record, 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('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 { return new Promise((resolve, reject) => this.crowdClient.ping((err: any) => { if (err) { reject(err); } resolve(); }), ); } async fetchCrowdUser(crowdUsername: string): Promise> { return new Promise((resolve, reject) => this.crowdClient.user.find(crowdUsername, (err: any, userResponse: Record) => { 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 | undefined> { return new Promise((resolve) => this.crowdClient.search('user', `email=" ${email} "`, (err: any, response: Record) => { if (err) { resolve(undefined); } resolve(response); }), ); } async authenticate(username: string, password: string): Promise | 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({ '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({ 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({ 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) => { if (err) { reject(err); } const user = res; try { const crowdUser: Record = 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, id: string) { const user = { username: this.cleanUsername(crowdUser.username), crowd_username: crowdUser.crowd_username, emails: [ { address: crowdUser.email, verified: settings.get('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; crowd_username: string })[]) || []; logger.info('Sync started...'); for (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) { 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?.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.error({ msg: 'Crowd user not authenticated due to an error', err }); 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()); } }); });