snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
import { DDP } from 'meteor/ddp';
import { DDPCommon } from 'meteor/ddp-common';
class MethodInvocation extends DDPCommon.MethodInvocation {
constructor(options) {
const result = super(options);
const currentInvocation = DDP._CurrentInvocation.get();
if (currentInvocation) {
this.twoFactorChecked = currentInvocation.twoFactorChecked;
}
return result;
}
}
DDPCommon.MethodInvocation = MethodInvocation;

View File

@@ -0,0 +1,70 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const settingsMock = sinon.stub();
const { EmailCheck } = proxyquire.noCallThru().load('./EmailCheck', {
'@rocket.chat/models': {
Users: {},
},
'meteor/accounts-base': {
Accounts: {
_bcryptRounds: () => '123',
},
},
'../../i18n': {
i18n: {
t: (key: string) => key,
},
},
'../../notifications/email/api': {
send: () => undefined,
},
'../../../settings': {
settings: {
get: settingsMock,
},
},
});
const normalUserMock = { services: { email2fa: { enabled: true } }, emails: [{ email: 'abc@gmail.com', verified: true }] };
const normalUserWithUnverifiedEmailMock = {
services: { email2fa: { enabled: true } },
emails: [{ email: 'abc@gmail.com', verified: false }],
};
const OAuthUserMock = { services: { google: {} }, emails: [{ email: 'abc@gmail.com', verified: true }] };
describe('EmailCheck', () => {
let emailCheck: typeof EmailCheck;
beforeEach(() => {
settingsMock.reset();
emailCheck = new EmailCheck();
});
it('should return EmailCheck is enabled for a normal user', () => {
settingsMock.returns(true);
const isEmail2FAEnabled = emailCheck.isEnabled(normalUserMock);
expect(isEmail2FAEnabled).to.be.equal(true);
});
it('should return EmailCheck is not enabled for a normal user with unverified email', () => {
settingsMock.returns(true);
const isEmail2FAEnabled = emailCheck.isEnabled(normalUserWithUnverifiedEmailMock);
expect(isEmail2FAEnabled).to.be.equal(false);
});
it('should return EmailCheck is not enabled for a OAuth user with setting being false', () => {
settingsMock.returns(true);
const isEmail2FAEnabled = emailCheck.isEnabled(OAuthUserMock);
expect(isEmail2FAEnabled).to.be.equal(false);
});
});

View File

@@ -0,0 +1,150 @@
import { isOAuthUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import bcrypt from 'bcrypt';
import { Accounts } from 'meteor/accounts-base';
import type { ICodeCheck, IProcessInvalidCodeResult, TwoFactorUser } from './ICodeCheck';
import { settings } from '../../../settings';
import { i18n } from '../../i18n';
import * as Mailer from '../../notifications/email/api';
export class EmailCheck implements ICodeCheck {
public readonly name: string = 'email';
private getUserVerifiedEmails(user: TwoFactorUser): string[] {
if (!Array.isArray(user.emails)) {
return [];
}
return user.emails.filter(({ verified }) => verified).map((e) => e.address);
}
public isEnabled(user: TwoFactorUser): boolean {
if (!settings.get('Accounts_TwoFactorAuthentication_By_Email_Enabled')) {
return false;
}
if (!settings.get('Accounts_twoFactorAuthentication_email_available_for_OAuth_users') && isOAuthUser(user)) {
return false;
}
if (!user.services?.email2fa?.enabled) {
return false;
}
return this.getUserVerifiedEmails(user).length > 0;
}
private async send2FAEmail(address: string, random: string, user: TwoFactorUser): Promise<void> {
const language = user.language || settings.get('Language') || 'en';
const t = i18n.getFixedT(language);
await Mailer.send({
to: address,
from: settings.get('From_Email'),
subject: 'Authentication code',
replyTo: undefined,
data: {
code: random.replace(/^(\d{3})/, '$1-'),
},
headers: undefined,
text: `
${t('Here_is_your_authentication_code')}
__code__
${t('Do_not_provide_this_code_to_anyone')}
${t('If_you_didnt_try_to_login_in_your_account_please_ignore_this_email')}
`,
html: `
<p>${t('Here_is_your_authentication_code')}</p>
<p style="font-size: 30px;">
<b>__code__</b>
</p>
<p>${t('Do_not_provide_this_code_to_anyone')}</p>
<p>${t('If_you_didnt_try_to_login_in_your_account_please_ignore_this_email')}</p>
`,
});
}
public async verify(user: TwoFactorUser, codeFromEmail: string): Promise<boolean> {
if (!this.isEnabled(user)) {
return false;
}
if (!user.services?.emailCode) {
return false;
}
// Remove non digits
codeFromEmail = codeFromEmail.replace(/([^\d])/g, '');
const { code, expire } = user.services.emailCode;
if (expire < new Date()) {
return false;
}
if (await bcrypt.compare(codeFromEmail, code)) {
await Users.removeEmailCodeOfUserId(user._id);
return true;
}
await Users.incrementInvalidEmailCodeAttempt(user._id);
return false;
}
public async sendEmailCode(user: TwoFactorUser): Promise<void> {
const emails = this.getUserVerifiedEmails(user);
const random = Random._randomString(6, '0123456789');
const encryptedRandom = await bcrypt.hash(random, Accounts._bcryptRounds());
const expire = new Date();
const expirationInSeconds = parseInt(settings.get('Accounts_TwoFactorAuthentication_By_Email_Code_Expiration'), 10);
expire.setSeconds(expire.getSeconds() + expirationInSeconds);
await Users.addEmailCodeByUserId(user._id, encryptedRandom, expire);
for (const address of emails) {
await this.send2FAEmail(address, random, user);
}
}
public async processInvalidCode(user: TwoFactorUser): Promise<IProcessInvalidCodeResult> {
await Users.removeExpiredEmailCodeOfUserId(user._id);
// Generate new code if the there isn't any code with more than 5 minutes to expire
const expireWithDelta = new Date();
expireWithDelta.setMinutes(expireWithDelta.getMinutes() - 5);
const emails = this.getUserVerifiedEmails(user);
const emailOrUsername = user.username || emails[0];
const hasValidCode =
user.services?.emailCode?.expire &&
user.services?.emailCode?.expire > expireWithDelta &&
!(await this.maxFaildedAttemtpsReached(user));
if (hasValidCode) {
return {
emailOrUsername,
codeGenerated: false,
codeExpires: user.services?.emailCode?.expire,
};
}
await this.sendEmailCode(user);
return {
codeGenerated: true,
emailOrUsername,
};
}
public async maxFaildedAttemtpsReached(user: TwoFactorUser) {
const maxAttempts = settings.get<number>('Accounts_TwoFactorAuthentication_Max_Invalid_Email_Code_Attempts');
return Users.maxInvalidEmailCodeAttemptsReached(user._id, maxAttempts);
}
}

View File

@@ -0,0 +1,38 @@
import { TwoFactorChallenges } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { EmailCheck } from './EmailCheck';
import type { TwoFactorUser } from './ICodeCheck';
export class EmailCheckForOAuth extends EmailCheck {
public override readonly name = 'email-oauth';
public readonly method = 'email';
public async sendTwoFactorChallenge(user: TwoFactorUser): Promise<string> {
const challengeId = await TwoFactorChallenges.createTwoFactorChallenge(user._id, 'email');
await this.sendEmailCode(user);
return challengeId;
}
public async verifyEmailTwoFactorChallenge(user: TwoFactorUser, challengeId: string, code: string): Promise<boolean> {
const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId);
if (!challenge) {
return false;
}
if (challenge.expireAt && challenge.expireAt < new Date()) {
throw new Meteor.Error('error-challenge-expired', 'challenge expired');
}
const isCodeValid = await this.verify(user, code);
if (!isCodeValid) {
return false;
}
await TwoFactorChallenges.removeByPendingChallengeId(challengeId);
return true;
}
}

View File

@@ -0,0 +1,22 @@
import type { IUser } from '@rocket.chat/core-typings';
/** The user fields the 2FA checks read; `getUserForCheck` projects exactly these. */
export type TwoFactorUser = Pick<IUser, '_id' | 'username' | 'emails' | 'language' | 'createdAt' | 'services'>;
export interface IProcessInvalidCodeResult {
codeGenerated: boolean;
codeExpires?: Date;
emailOrUsername?: string;
}
export interface ICodeCheck {
readonly name: string;
isEnabled(user: TwoFactorUser, force?: boolean): boolean;
verify(user: TwoFactorUser, code: string, force?: boolean): Promise<boolean>;
processInvalidCode(user: TwoFactorUser): Promise<IProcessInvalidCodeResult>;
maxFaildedAttemtpsReached(user: TwoFactorUser): Promise<boolean>;
}

View File

@@ -0,0 +1,48 @@
import { Accounts } from 'meteor/accounts-base';
import type { Meteor } from 'meteor/meteor';
import type { ICodeCheck, IProcessInvalidCodeResult, TwoFactorUser } from './ICodeCheck';
import { settings } from '../../../settings';
export class PasswordCheckFallback implements ICodeCheck {
public readonly name = 'password';
public isEnabled(user: TwoFactorUser, force: boolean): boolean {
if (force) {
return true;
}
// TODO: Remove this setting for version 4.0 forcing the
// password fallback for who has password set.
if (settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) {
return user.services?.password?.bcrypt != null;
}
return false;
}
public async verify(user: TwoFactorUser, code: string, force: boolean): Promise<boolean> {
if (!this.isEnabled(user, force)) {
return false;
}
const passCheck = await Accounts._checkPasswordAsync(user as Meteor.User, {
digest: code.toLowerCase(),
algorithm: 'sha-256',
});
if (passCheck.error) {
return false;
}
return true;
}
public async processInvalidCode(): Promise<IProcessInvalidCodeResult> {
return {
codeGenerated: false,
};
}
public async maxFaildedAttemtpsReached(_user: TwoFactorUser): Promise<boolean> {
return false;
}
}

View File

@@ -0,0 +1,43 @@
import type { ICodeCheck, IProcessInvalidCodeResult, TwoFactorUser } from './ICodeCheck';
import { settings } from '../../../settings';
import { TOTP } from '../lib/totp';
export class TOTPCheck implements ICodeCheck {
public readonly name: string = 'totp';
public isEnabled(user: TwoFactorUser): boolean {
if (!settings.get('Accounts_TwoFactorAuthentication_By_TOTP_Enabled')) {
return false;
}
return user.services?.totp?.enabled === true;
}
public async verify(user: TwoFactorUser, code: string): Promise<boolean> {
if (!this.isEnabled(user)) {
return false;
}
if (!user.services?.totp?.secret) {
return false;
}
return TOTP.verify({
secret: user.services?.totp?.secret,
token: code,
userId: user._id,
backupTokens: user.services?.totp?.hashedBackup,
});
}
public async processInvalidCode(): Promise<IProcessInvalidCodeResult> {
// Nothing to do
return {
codeGenerated: false,
};
}
public async maxFaildedAttemtpsReached(_user: TwoFactorUser): Promise<boolean> {
return false;
}
}

View File

@@ -0,0 +1,36 @@
import { TwoFactorChallenges } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import type { TwoFactorUser } from './ICodeCheck';
import { TOTPCheck } from './TOTPCheck';
export class TOTPCheckForOAuth extends TOTPCheck {
public override readonly name = 'totp-oauth';
public readonly method = 'totp';
public async sendTwoFactorChallenge(user: TwoFactorUser): Promise<string> {
return TwoFactorChallenges.createTwoFactorChallenge(user._id, 'totp');
}
public async verifyEmailTwoFactorChallenge(user: TwoFactorUser, challengeId: string, code: string): Promise<boolean> {
const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId);
if (!challenge) {
return false;
}
if (challenge.expireAt && challenge.expireAt < new Date()) {
throw new Meteor.Error('error-challenge-expired', 'challenge expired');
}
const isCodeValid = await this.verify(user, code);
if (!isCodeValid) {
return false;
}
await TwoFactorChallenges.removeByPendingChallengeId(challengeId);
return true;
}
}

View File

@@ -0,0 +1,193 @@
import { expect } from 'chai';
import { before, after, beforeEach, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const settingsGet = sinon.stub();
const totpEnabled = sinon.stub().returns(false);
class MeteorError extends Error {
error: string;
details?: unknown;
constructor(error: string, reason?: string, details?: unknown) {
super(reason ?? error);
this.error = error;
this.details = details;
}
}
// Disabled second-factor methods so the password fallback is the only candidate.
class TOTPCheck {
name = 'totp';
isEnabled(): boolean {
return totpEnabled();
}
async processInvalidCode(): Promise<{ codeGenerated: boolean }> {
return { codeGenerated: false };
}
}
class EmailCheck {
name = 'email';
isEnabled(): boolean {
return false;
}
}
class PasswordCheckFallback {
name = 'password';
isEnabled(): boolean {
return true;
}
async verify(): Promise<boolean> {
return false;
}
async processInvalidCode(): Promise<{ codeGenerated: boolean }> {
return { codeGenerated: false };
}
async maxFaildedAttemtpsReached(): Promise<boolean> {
return false;
}
}
const { checkCodeForUser, getFingerprintFromConnection } = proxyquire.noCallThru().load('./index', {
'@rocket.chat/models': {
Users: {
findOneById: async () => null,
setTwoFactorAuthorizationHashAndUntilForUserIdAndToken: async () => undefined,
},
},
'meteor/accounts-base': {
Accounts: {
_getLoginToken: () => 'token-hash',
},
},
'meteor/meteor': {
Meteor: { Error: MeteorError },
},
'./TOTPCheck': { TOTPCheck },
'./EmailCheck': { EmailCheck },
'./PasswordCheckFallback': { PasswordCheckFallback },
'../../shared/getModifiedHttpHeaders': {
normalizeHeaders: (headers: unknown) => headers,
},
'../../../settings': {
settings: { get: settingsGet },
},
});
const REMEMBER_FOR_SECONDS = 1800;
const buildUser = (createdAt: Date) => ({
_id: 'user-id',
createdAt,
services: {
password: { bcrypt: 'hashed' },
resume: { loginTokens: [{ hashedToken: 'token-hash' }] },
},
});
const connection = {
id: 'connection-id',
httpHeaders: { 'user-agent': 'agent' },
clientAddress: '127.0.0.1',
};
describe('checkCodeForUser - post-registration grace window', () => {
let originalTestMode: string | undefined;
before(() => {
originalTestMode = process.env.TEST_MODE;
delete process.env.TEST_MODE;
settingsGet.callsFake((key: string) => {
switch (key) {
case 'Accounts_TwoFactorAuthentication_Enabled':
return true;
case 'Accounts_TwoFactorAuthentication_RememberFor':
return REMEMBER_FOR_SECONDS;
case 'Accounts_TwoFactorAuthentication_Enforce_Password_Fallback':
return true;
default:
return false;
}
});
});
after(() => {
if (originalTestMode === undefined) {
delete process.env.TEST_MODE;
} else {
process.env.TEST_MODE = originalTestMode;
}
});
beforeEach(() => {
totpEnabled.reset();
totpEnabled.returns(false);
});
it('should not prompt a freshly registered user even when disableRememberMe is set (Setup Wizard regression)', async () => {
const user = buildUser(new Date());
const result = await checkCodeForUser({
user,
options: { disableRememberMe: true },
connection,
});
expect(result).to.be.equal(true);
});
it('should still prompt a freshly registered user who already has a 2FA method configured', async () => {
totpEnabled.returns(true);
const user = buildUser(new Date());
await expect(
checkCodeForUser({
user,
options: { disableRememberMe: true },
connection,
}),
).to.be.rejectedWith('TOTP Required');
});
it('should still prompt when the post-registration grace window has expired and disableRememberMe is set', async () => {
const user = buildUser(new Date(Date.now() - (REMEMBER_FOR_SECONDS + 60) * 1000));
await expect(
checkCodeForUser({
user,
options: { disableRememberMe: true },
connection,
}),
).to.be.rejectedWith('TOTP Required');
});
it('should ignore a remembered authorization when disableRememberMe is set and grace window expired', async () => {
const user = buildUser(new Date(Date.now() - (REMEMBER_FOR_SECONDS + 60) * 1000));
// A previously remembered 2FA authorization that is still valid in time.
user.services.resume.loginTokens[0] = {
hashedToken: 'token-hash',
twoFactorAuthorizedUntil: new Date(Date.now() + 60 * 1000),
twoFactorAuthorizedHash: getFingerprintFromConnection(connection),
} as (typeof user.services.resume.loginTokens)[number];
await expect(
checkCodeForUser({
user,
options: { disableRememberMe: true },
connection,
}),
).to.be.rejectedWith('TOTP Required');
});
});

View File

@@ -0,0 +1,135 @@
import { expect } from 'chai';
import { afterEach, beforeEach, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const settingsMock = sinon.stub();
const getLoginTokenStub = sinon.stub();
class TOTPCheckMock {
name = 'totp';
isEnabled() {
return true;
}
async processInvalidCode() {
return {};
}
async verify() {
return false;
}
async maxFaildedAttemtpsReached() {
return false;
}
}
class DisabledCheckMock {
name = 'email';
isEnabled() {
return false;
}
}
class MeteorErrorMock extends Error {
error: string;
details: unknown;
constructor(error: string, reason?: string, details?: unknown) {
super(reason);
this.error = error;
this.details = details;
}
}
const { checkCodeForUser } = proxyquire.noCallThru().load('./index', {
'./TOTPCheck': { TOTPCheck: TOTPCheckMock },
'./EmailCheck': { EmailCheck: DisabledCheckMock },
'./PasswordCheckFallback': { PasswordCheckFallback: class extends DisabledCheckMock {} },
'../../shared/getModifiedHttpHeaders': { normalizeHeaders: (headers: unknown) => headers },
'../../../settings': { settings: { get: settingsMock } },
'@rocket.chat/models': {
Users: {
findOneById: async () => null,
setTwoFactorAuthorizationHashAndUntilForUserIdAndToken: async () => undefined,
},
},
'meteor/accounts-base': { Accounts: { _getLoginToken: getLoginTokenStub } },
'meteor/meteor': { Meteor: { Error: MeteorErrorMock } },
});
const HASHED_TOKEN = 'hashed-login-token';
const userWithBypassToken = {
_id: 'user-id',
services: { resume: { loginTokens: [{ hashedToken: HASHED_TOKEN, bypassTwoFactor: true }] } },
} as any;
const connection = {
id: 'connection-id',
clientAddress: '127.0.0.1',
httpHeaders: {},
} as any;
describe('checkCodeForUser - bypassTwoFactor token resolution (SUP-1064)', () => {
let testMode: string | undefined;
beforeEach(() => {
// TEST_MODE short-circuits the whole check; remove it so the token resolution path runs.
testMode = process.env.TEST_MODE;
delete process.env.TEST_MODE;
settingsMock.reset();
settingsMock.withArgs('Accounts_TwoFactorAuthentication_Enabled').returns(true);
getLoginTokenStub.reset();
});
afterEach(() => {
if (testMode !== undefined) {
process.env.TEST_MODE = testMode;
}
});
it('should honor a bypassTwoFactor token resolved from the REST connection (connection.token)', async () => {
// REST: the token is not registered in account data, only carried on the connection.
getLoginTokenStub.returns(undefined);
const authorized = await checkCodeForUser({
user: userWithBypassToken,
connection: { ...connection, token: HASHED_TOKEN },
options: {},
});
expect(authorized).to.be.equal(true);
});
it('should honor a bypassTwoFactor token resolved from account data (DDP, _getLoginToken)', async () => {
// DDP: the token is registered in Accounts._accountData and read via _getLoginToken.
getLoginTokenStub.returns(HASHED_TOKEN);
const authorized = await checkCodeForUser({
user: userWithBypassToken,
connection: { ...connection, token: undefined },
options: {},
});
expect(authorized).to.be.equal(true);
});
it('should still require a second factor when the token cannot be resolved from either source', async () => {
// Regression guard: this is the buggy state (#38017) the fix addresses.
getLoginTokenStub.returns(undefined);
await expect(
checkCodeForUser({
user: userWithBypassToken,
connection: { ...connection, token: undefined },
options: {},
}),
).to.be.rejectedWith('TOTP Required');
});
});

View File

@@ -0,0 +1,268 @@
import crypto from 'node:crypto';
import type { IUser, IMethodConnection } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import { EmailCheck } from './EmailCheck';
import type { ICodeCheck, TwoFactorUser } from './ICodeCheck';
import { PasswordCheckFallback } from './PasswordCheckFallback';
import { TOTPCheck } from './TOTPCheck';
import { settings } from '../../../settings';
import { normalizeHeaders } from '../../shared/getModifiedHttpHeaders';
export interface ITwoFactorOptions {
disablePasswordFallback?: boolean;
disableRememberMe?: boolean;
requireSecondFactor?: boolean; // whether any two factor should be required
}
const totpCheck = new TOTPCheck();
export const emailCheck = new EmailCheck();
const passwordCheckFallback = new PasswordCheckFallback();
const checkMethods = new Map<string, ICodeCheck>();
checkMethods.set(totpCheck.name, totpCheck);
checkMethods.set(emailCheck.name, emailCheck);
function getMethodByNameOrFirstActiveForUser(user: TwoFactorUser, name?: string): ICodeCheck | undefined {
if (name && checkMethods.has(name)) {
return checkMethods.get(name);
}
return Array.from(checkMethods.values()).find((method) => method.isEnabled(user));
}
function getAvailableMethodNames(user: TwoFactorUser): string[] {
return (
Array.from(checkMethods)
.filter(([, method]) => method.isEnabled(user))
.map(([name]) => name) || []
);
}
export async function getUserForCheck(userId: string): Promise<TwoFactorUser | null> {
return Users.findOneById(userId, {
projection: {
username: 1,
emails: 1,
language: 1,
createdAt: 1,
services: 1,
},
});
}
export function getFingerprintFromConnection(connection: IMethodConnection): string {
const data = JSON.stringify({
userAgent: connection.httpHeaders['user-agent'],
clientAddress: connection.clientAddress,
});
return crypto.createHash('sha256').update(data).digest('hex');
}
export function getRememberDate(from: Date = new Date()): Date | undefined {
const rememberFor = settings.get<number>('Accounts_TwoFactorAuthentication_RememberFor');
if (rememberFor <= 0) {
return;
}
const expires = new Date(from);
expires.setSeconds(expires.getSeconds() + rememberFor);
return expires;
}
function isAuthorizedForToken(connection: IMethodConnection, user: TwoFactorUser, options: ITwoFactorOptions): boolean {
// Resolve the current login token from both transports:
// - DDP: the login flow registers it in `Accounts._accountData`, read via `_getLoginToken`.
// - REST: it is not registered in account data, so it is carried on `connection.token`.
// Both are needed; REST is the fallback that fixes `bypassTwoFactor` PATs (SUP-1064).
const currentToken = Accounts._getLoginToken(connection.id) || connection.token;
const tokenObject = user.services?.resume?.loginTokens?.find((i) => i.hashedToken === currentToken);
if (!tokenObject) {
return false;
}
// if any two factor is required, early abort
if (options.requireSecondFactor) {
return false;
}
if ('bypassTwoFactor' in tokenObject && tokenObject.bypassTwoFactor === true) {
return true;
}
// Skip 2FA for a freshly registered user who has not set up any 2FA method yet,
// until the grace period that starts at registration expires.
// (e.g. the Setup Wizard saving settings between steps before any 2FA is configured)
const rememberPeriodEnd = user.createdAt && getRememberDate(user.createdAt);
const isWithinRememberPeriod = rememberPeriodEnd && rememberPeriodEnd >= new Date();
const hasNoTwoFactorMethod = getAvailableMethodNames(user).length === 0;
if (isWithinRememberPeriod && hasNoTwoFactorMethod) {
return true;
}
if (options.disableRememberMe === true) {
return false;
}
if (!tokenObject.twoFactorAuthorizedUntil || !tokenObject.twoFactorAuthorizedHash) {
return false;
}
if (tokenObject.twoFactorAuthorizedUntil < new Date()) {
return false;
}
if (tokenObject.twoFactorAuthorizedHash !== getFingerprintFromConnection(connection)) {
return false;
}
return true;
}
export async function rememberAuthorizationByToken(token: string, userId: IUser['_id'], connection: IMethodConnection): Promise<void> {
const user = await Users.findOneByIdAndLoginHashedToken(userId, token, { projection: { _id: 1, services: 1 } });
if (!user) {
throw new Meteor.Error('error-user-not-found', 'user not found');
}
const expires = getRememberDate();
if (!expires) {
return;
}
await Users.setTwoFactorAuthorizationHashAndUntilForUserIdAndToken(user._id, token, getFingerprintFromConnection(connection), expires);
}
async function rememberAuthorization(connection: IMethodConnection, user: TwoFactorUser): Promise<void> {
// Same dual-transport resolution as `isAuthorizedForToken`: DDP reads from `Accounts._accountData`
// via `_getLoginToken`, REST falls back to the token carried on `connection.token`.
const currentToken = Accounts._getLoginToken(connection.id) || connection.token;
const expires = getRememberDate();
if (!expires) {
return;
}
if (!currentToken) {
return;
}
await Users.setTwoFactorAuthorizationHashAndUntilForUserIdAndToken(
user._id,
currentToken,
getFingerprintFromConnection(connection),
expires,
);
}
interface ICheckCodeForUser {
user: TwoFactorUser | string;
code?: string;
method?: string;
options?: ITwoFactorOptions;
connection?: IMethodConnection;
}
export const getSecondFactorMethod = (
user: TwoFactorUser,
method: string | undefined,
options: ITwoFactorOptions,
): ICodeCheck | undefined => {
// try first getting one of the available methods or the one that was already provided
const selectedMethod = getMethodByNameOrFirstActiveForUser(user, method);
if (selectedMethod) {
return selectedMethod;
}
// if none found but a second factor is required, chose the password check
if (options.requireSecondFactor) {
return passwordCheckFallback;
}
// check if password fallback is enabled
if (!options.disablePasswordFallback && passwordCheckFallback.isEnabled(user, !!options.requireSecondFactor)) {
return passwordCheckFallback;
}
};
export async function checkCodeForUser({ user, code, method, options = {}, connection }: ICheckCodeForUser): Promise<boolean> {
if (process.env.TEST_MODE && !options.requireSecondFactor) {
return true;
}
if (!settings.get('Accounts_TwoFactorAuthentication_Enabled')) {
return true;
}
let existingUser: TwoFactorUser | null;
if (typeof user === 'string') {
existingUser = await getUserForCheck(user);
} else {
existingUser = user;
}
if (!existingUser) {
throw new Meteor.Error('totp-user-not-found', 'TOTP User not found');
}
const headers = normalizeHeaders(connection?.httpHeaders);
if (!code && !method && headers?.['x-2fa-code'] && headers['x-2fa-method']) {
code = headers['x-2fa-code'];
method = headers['x-2fa-method'];
}
if (connection && isAuthorizedForToken(connection, existingUser, options)) {
return true;
}
// select a second factor method or return if none is found/available
const selectedMethod = getSecondFactorMethod(existingUser, method, options);
if (!selectedMethod) {
return true;
}
const data = await selectedMethod.processInvalidCode(existingUser);
const availableMethods = getAvailableMethodNames(existingUser);
if (!code) {
throw new Meteor.Error('totp-required', 'TOTP Required', {
method: selectedMethod.name,
...data,
availableMethods,
});
}
const valid = await selectedMethod.verify(existingUser, code, options.requireSecondFactor);
if (!valid) {
const tooManyFailedAttempts = await selectedMethod.maxFaildedAttemtpsReached(existingUser);
if (tooManyFailedAttempts) {
throw new Meteor.Error('totp-max-attempts', 'TOTP Maximun Failed Attempts Reached', {
method: selectedMethod.name,
...data,
availableMethods,
});
}
throw new Meteor.Error('totp-invalid', 'TOTP Invalid', {
method: selectedMethod.name,
...data,
availableMethods,
});
}
if (options.disableRememberMe !== true && connection) {
await rememberAuthorization(connection, existingUser);
}
return true;
}

View File

@@ -0,0 +1,84 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { settings } from '../../../settings';
import { i18n } from '../../i18n';
import { isUserIdFederated } from '../../isUserIdFederated';
import * as Mailer from '../../notifications/email/api';
import { notifyOnUserChange } from '../../notifyListener';
const sendResetNotification = async function (uid: string): Promise<void> {
const user = await Users.findOneById<Pick<IUser, 'language' | 'emails'>>(uid, {
projection: { language: 1, emails: 1 },
});
if (!user) {
throw new Meteor.Error('invalid-user');
}
const language = user.language || settings.get('Language') || 'en';
const addresses = user.emails?.filter(({ verified }) => Boolean(verified)).map((e) => e.address);
if (!addresses?.length) {
return;
}
const t = i18n.getFixedT(language);
const text = `
${t('Your_TOTP_has_been_reset')}
${t('TOTP_Reset_Other_Key_Warning')}
`;
const html = `
<p>${t('Your_TOTP_has_been_reset')}</p>
<p>${t('TOTP_Reset_Other_Key_Warning')}</p>
`;
const from = settings.get('From_Email');
const subject = t('TOTP_reset_email');
for (const address of addresses) {
try {
await Mailer.send({
to: address,
from,
subject,
text,
html,
} as any);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${message}`, {
function: 'resetUserTOTP',
message,
});
}
}
};
export async function resetTOTP(userId: string, notifyUser = false): Promise<boolean> {
if (notifyUser) {
await sendResetNotification(userId);
}
const isUserFederated = await isUserIdFederated(userId);
if (isUserFederated) {
throw new Meteor.Error('error-not-allowed', 'Federated Users cant have TOTP', { function: 'resetTOTP' });
}
const result = await Users.resetTOTPById(userId);
if (result?.modifiedCount === 1) {
await Users.unsetLoginTokens(userId);
void notifyOnUserChange({
clientAction: 'updated',
id: userId,
diff: {
'services.resume.loginTokens': [],
},
});
return true;
}
return false;
}

View File

@@ -0,0 +1,154 @@
import { Users } from '@rocket.chat/models';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import { notifyOnUserChange, notifyOnUserChangeAsync } from '../../notifyListener';
import { TOTP } from '../lib/totp';
const requireUser = async (userId: string | null) => {
if (!userId) {
throw new Meteor.Error('not-authorized');
}
const user = await Users.findOneById(userId);
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user');
}
return user;
};
export const enableTotp = async (userId: string | null): Promise<{ secret: string; url: string }> => {
const user = await requireUser(userId);
if (!user.username) {
throw new Meteor.Error('error-invalid-user', 'Invalid user');
}
if (user.services?.totp?.enabled) {
throw new Meteor.Error('error-2fa-already-enabled');
}
const secret = TOTP.generateSecret();
await Users.disable2FAAndSetTempSecretByUserId(user._id, secret.base32);
return {
secret: secret.base32,
url: TOTP.generateOtpauthURL(secret, user.username),
};
};
export const disableTotp = async (userId: string | null, code: string): Promise<boolean> => {
const user = await requireUser(userId);
if (!user.services?.totp?.enabled) {
return false;
}
const verified = await TOTP.verify({
secret: user.services.totp.secret,
token: code,
userId: user._id,
backupTokens: user.services.totp.hashedBackup,
});
if (!verified) {
return false;
}
const { modifiedCount } = await Users.disable2FAByUserId(user._id);
if (!modifiedCount) {
return false;
}
void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff: { 'services.totp.enabled': false } });
return true;
};
export const validateTotpTempToken = async (userId: string | null, userToken: string, authToken?: string): Promise<{ codes: string[] }> => {
const user = await requireUser(userId);
if (!user.services?.totp?.tempSecret) {
throw new Meteor.Error('invalid-totp');
}
const verified = await TOTP.verify({
secret: user.services.totp.tempSecret,
token: userToken,
});
if (!verified) {
throw new Meteor.Error('invalid-totp');
}
const { codes, hashedCodes } = TOTP.generateCodes();
await Users.enable2FAAndSetSecretAndCodesByUserId(user._id, user.services.totp.tempSecret, hashedCodes);
if (authToken) {
const hashedToken = Accounts._hashLoginToken(authToken);
const { modifiedCount } = await Users.removeNonPATLoginTokensExcept(user._id, hashedToken);
if (modifiedCount > 0) {
void notifyOnUserChangeAsync(async () => {
const refreshed = await Users.findOneById(user._id, {
projection: { 'services.resume.loginTokens': 1, 'services.totp': 1 },
});
return {
clientAction: 'updated',
id: user._id,
diff: {
'services.resume.loginTokens': refreshed?.services?.resume?.loginTokens,
...(refreshed?.services?.totp && { 'services.totp.enabled': refreshed.services.totp.enabled }),
},
};
});
} else {
void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff: { 'services.totp.enabled': true } });
}
} else {
void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff: { 'services.totp.enabled': true } });
}
return { codes };
};
export const regenerateTotpCodes = async (userId: string | null, userToken: string): Promise<{ codes: string[] } | undefined> => {
const user = await requireUser(userId);
if (!user.services?.totp?.enabled) {
throw new Meteor.Error('invalid-totp');
}
const verified = await TOTP.verify({
secret: user.services.totp.secret,
token: userToken,
userId: user._id,
backupTokens: user.services.totp.hashedBackup,
});
if (!verified) {
return undefined;
}
const { codes, hashedCodes } = TOTP.generateCodes();
await Users.update2FABackupCodesByUserId(user._id, hashedCodes);
return { codes };
};
export const codesRemainingTotp = async (userId: string | null): Promise<{ remaining: number }> => {
const user = await requireUser(userId);
if (!user.services?.totp?.enabled) {
throw new Meteor.Error('invalid-totp');
}
return {
remaining: user.services.totp.hashedBackup?.length ?? 0,
};
};

View File

@@ -0,0 +1,78 @@
import { Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { SHA256 } from '@rocket.chat/sha256';
import speakeasy from 'speakeasy';
import { settings } from '../../../settings';
export const TOTP = {
generateSecret(): speakeasy.GeneratedSecret {
return speakeasy.generateSecret();
},
generateOtpauthURL(secret: speakeasy.GeneratedSecret, username: string): string {
return speakeasy.otpauthURL({
secret: secret.ascii,
label: `Rocket.Chat:${username}`,
});
},
async verify({
secret,
token,
backupTokens,
userId,
}: {
secret: string;
token: string;
backupTokens?: string[];
userId?: string;
}): Promise<boolean> {
// validates a backup code
if (token.length === 8 && backupTokens) {
const hashedCode = SHA256(token);
const usedCode = backupTokens.indexOf(hashedCode);
if (usedCode !== -1 && userId) {
backupTokens.splice(usedCode, 1);
// mark the code as used (remove it from the list)
await Users.update2FABackupCodesByUserId(userId, backupTokens);
return true;
}
return false;
}
const maxDelta = settings.get<number>('Accounts_TwoFactorAuthentication_MaxDelta');
if (maxDelta) {
const verifiedDelta = speakeasy.totp.verifyDelta({
secret,
encoding: 'base32',
token,
window: maxDelta,
});
return verifiedDelta !== undefined;
}
return speakeasy.totp.verify({
secret,
encoding: 'base32',
token,
});
},
generateCodes(): { codes: string[]; hashedCodes: string[] } {
// generate 12 backup codes
const codes = [];
const hashedCodes = [];
for (let i = 0; i < 12; i++) {
const code = Random.id(8);
codes.push(code);
hashedCodes.push(SHA256(code));
}
return { codes, hashedCodes };
},
};

View File

@@ -0,0 +1,110 @@
import { Accounts } from 'meteor/accounts-base';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { OAuth } from 'meteor/oauth';
import { checkCodeForUser } from './code';
import { callbacks } from '../callbacks';
const isMeteorError = (error: any): error is Meteor.Error => {
return error?.meteorError !== undefined;
};
const isCredentialWithError = (credential: any): credential is { error: Error } => {
return credential?.error !== undefined;
};
Accounts.registerLoginHandler('totp', function (options) {
if (!options.totp?.code) {
return;
}
// @ts-expect-error - not sure how to type this yet
return Accounts._runLoginHandlers(this, options.totp.login);
});
callbacks.add(
'onValidateLogin',
async (login) => {
if (
!login.user ||
login.type === 'resume' ||
login.type === 'proxy' ||
login.type === 'cas' ||
(login.type === 'password' && login.methodName === 'resetPassword') ||
login.methodName === 'verifyEmail'
) {
return login;
}
const [loginArgs] = login.methodArguments;
const { totp } = loginArgs;
await checkCodeForUser({
user: login.user,
code: totp?.code,
options: { disablePasswordFallback: true },
});
return login;
},
callbacks.priority.MEDIUM,
'2fa',
);
const copyTo = <T extends Error>(from: T, to: T): T => {
Object.getOwnPropertyNames(to).forEach((key) => {
const idx: keyof T = key as keyof T;
to[idx] = from[idx];
});
return to;
};
const recreateError = (errorDoc: Error | Meteor.Error): Error | Meteor.Error => {
if (isMeteorError(errorDoc)) {
const error = new Meteor.Error('');
return copyTo(errorDoc, error);
}
const error = new Error();
return copyTo(errorDoc, error);
};
OAuth._retrievePendingCredential = async function (key, ...args): Promise<string | Error | void> {
const credentialSecret = args.length > 0 && args[0] !== undefined ? args[0] : undefined;
check(key, String);
const pendingCredential = await OAuth._pendingCredentials.findOneAsync({
key,
credentialSecret,
});
if (!pendingCredential) {
return;
}
if (isCredentialWithError(pendingCredential.credential)) {
await OAuth._pendingCredentials.removeAsync({
_id: pendingCredential._id,
});
return recreateError(pendingCredential.credential.error);
}
// Work-around to make the credentials reusable for 2FA
const future = new Date();
future.setMinutes(future.getMinutes() + 2);
await OAuth._pendingCredentials.updateAsync(
{
_id: pendingCredential._id,
},
{
$set: {
createdAt: future,
},
},
);
return OAuth.openSecret(pendingCredential.credential);
};

View File

@@ -0,0 +1,51 @@
import { Meteor } from 'meteor/meteor';
import type { ITwoFactorOptions } from './code';
import { checkCodeForUser } from './code';
export type AuthenticatedContext = {
userId: string;
token: string;
connection: {
id: string;
clientAddress: string;
httpHeaders: Record<string, string>;
};
twoFactorChecked?: boolean;
};
export const twoFactorRequired = <TFunction extends (this: any, ...args: any) => Promise<any>>(
fn: ThisParameterType<TFunction> extends AuthenticatedContext
? TFunction
: (this: AuthenticatedContext, ...args: Parameters<TFunction>) => ReturnType<TFunction>,
options?: ITwoFactorOptions,
) =>
async function (this, ...args) {
if (!this.userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'twoFactorRequired' });
}
// get two factor options from last item of args and remove it
const twoFactor = args.pop();
if (twoFactor) {
if (twoFactor.twoFactorCode && twoFactor.twoFactorMethod) {
await checkCodeForUser({
user: this.userId,
connection: this.connection || undefined,
code: twoFactor.twoFactorCode,
method: twoFactor.twoFactorMethod,
options,
});
this.twoFactorChecked = true;
} else {
// if it was not two factor options, put it back
args.push(twoFactor);
}
}
if (!this.twoFactorChecked) {
await checkCodeForUser({ user: this.userId, connection: this.connection || undefined, options });
}
return fn.apply(this, args);
} as (this: ThisParameterType<TFunction>, ...args: Parameters<TFunction>) => ReturnType<TFunction>;

View File

@@ -0,0 +1,69 @@
import type {
IOutboundEmailMessageProvider,
IOutboundMessageProviders,
IOutboundPhoneMessageProvider,
} from '@rocket.chat/apps-engine/definition/outboundCommunication';
import type { ValidOutboundProvider, IOutboundProvider, IOutboundMessageProvider } from '@rocket.chat/core-typings';
export class OutboundMessageProvider implements IOutboundMessageProvider {
private readonly outboundMessageProviders: Map<ValidOutboundProvider, IOutboundMessageProviders[]>;
constructor() {
this.outboundMessageProviders = new Map([
['phone', []],
['email', []],
]);
}
public findOneByProviderId(providerId: string) {
for (const providers of this.outboundMessageProviders.values()) {
for (const provider of providers) {
if (provider.appId === providerId) {
return provider;
}
}
}
return undefined;
}
public registerPhoneProvider(provider: IOutboundPhoneMessageProvider): void {
this.outboundMessageProviders.set('phone', [...(this.outboundMessageProviders.get('phone') || []), provider]);
}
public registerEmailProvider(provider: IOutboundEmailMessageProvider): void {
this.outboundMessageProviders.set('email', [...(this.outboundMessageProviders.get('email') || []), provider]);
}
public getOutboundMessageProviders(type?: ValidOutboundProvider): IOutboundProvider[] {
if (type) {
return Array.from(this.outboundMessageProviders.get(type)?.values() || []).map((provider) => ({
providerId: provider.appId,
providerName: provider.name,
providerType: provider.type,
...(provider.documentationUrl && { documentationUrl: provider.documentationUrl }),
...(provider.supportsTemplates && { supportsTemplates: provider.supportsTemplates }),
}));
}
return Array.from(this.outboundMessageProviders.values())
.flatMap((providers) => providers)
.map((provider) => ({
providerId: provider.appId,
providerName: provider.name,
supportsTemplates: provider.supportsTemplates,
providerType: provider.type,
}));
}
public unregisterProvider(appId: string, providerType: ValidOutboundProvider): void {
const providers = this.outboundMessageProviders.get(providerType);
if (!providers) {
return;
}
this.outboundMessageProviders.set(
providerType,
providers.filter((provider: IOutboundMessageProviders) => provider.appId !== appId),
);
}
}

View File

@@ -0,0 +1,17 @@
import { DDPRateLimiter } from 'meteor/ddp-rate-limiter';
export const RateLimiterClass = new (class {
limitMethod(methodName, numRequests, timeInterval, matchers) {
if (process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api') {
return;
}
const match = {
type: 'method',
name: methodName,
};
Object.entries(matchers).forEach(([key, matcher]) => {
match[key] = (...args) => matcher(...args);
});
return DDPRateLimiter.addRule(match, numRequests, timeInterval);
}
})();

View File

@@ -0,0 +1,297 @@
import { faker } from '@faker-js/faker';
import type { IAuditServerUserActor, IUser } from '@rocket.chat/core-typings';
import type { Updater } from '@rocket.chat/models';
import { UpdaterImpl } from '@rocket.chat/models';
import { UserChangedAuditStore } from './userChanged';
import { createFakeUser } from '../../../tests/mocks/data';
const makeFakeActor = (): Omit<IAuditServerUserActor, 'type'> => {
return {
ip: faker.internet.ip(),
useragent: faker.internet.userAgent(),
_id: faker.database.mongodbObjectId(),
username: faker.internet.userName(),
};
};
const createUserAndUpdater = (overrides?: Partial<IUser>): [IUser, Updater<IUser>, Omit<IAuditServerUserActor, 'type'>] => {
const originalUser = createFakeUser(overrides);
const updater = new UpdaterImpl<IUser>();
const actor = makeFakeActor();
return [originalUser, updater, actor];
};
const createEmailsField = (address?: string, verified = true) => {
return {
emails: [
{
address: address || faker.internet.email(),
verified,
},
],
};
};
jest.mock('@rocket.chat/models', () => {
return {
UpdaterImpl: jest.requireActual('@rocket.chat/models').UpdaterImpl,
ServerEvents: {
createAuditServerEvent: (...args: any) => args,
},
};
});
const createObfuscatedFields = (_2faEnabled = true): Pick<IUser, 'services' | 'e2e' | 'oauth'> => {
return {
services: {
password: {
bcrypt: faker.string.uuid(),
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore this field is not in IUser, but is present in DB
enroll: {
token: faker.string.uuid(),
email: faker.internet.email(),
when: faker.date.past(),
reason: 'enroll',
},
},
email2fa: {
enabled: _2faEnabled,
changedAt: faker.date.past(),
},
email: {
verificationTokens: [
{
token: faker.string.uuid(),
address: faker.internet.email(),
when: faker.date.past(),
},
],
},
resume: {
loginTokens: [
{
when: faker.date.past(),
hashedToken: faker.string.uuid(),
twoFactorAuthorizedHash: faker.string.uuid(),
twoFactorAuthorizedUntil: faker.date.past(),
},
],
},
},
e2e: {
private_key: faker.string.uuid(),
public_key: faker.string.uuid(),
},
inviteToken: faker.string.uuid(),
oauth: { authorizedClients: [faker.string.uuid(), faker.string.uuid()] },
};
};
const getObfuscatedFields = (email2faState: { enabled: boolean; changedAt: Date }) => ({
e2e: '****',
oauth: '****',
inviteToken: '****',
services: getObfuscatedServices(email2faState),
});
const getObfuscatedServices = (email2faState: { enabled: boolean; changedAt: Date }) => {
return {
password: '****',
email2fa: email2faState,
email: '****',
resume: '****',
};
};
describe('userChanged audit module', () => {
it('should build event with only name and username fields', async () => {
const [user, updater, actor] = createUserAndUpdater({ ...createEmailsField() });
const store = new UserChangedAuditStore(actor);
const [newUsername, newName] = [faker.internet.userName(), faker.person.fullName()];
updater.set('username', newUsername);
updater.set('name', newName);
store.setOriginalUser(user as IUser);
store.setUpdateFilter(updater.getUpdateFilter());
const event = await store.commitAuditEvent();
expect(event).toEqual([
'user.changed',
{
user: { _id: user._id, username: user.username },
user_data: { username: user.username, name: user.name },
operation: { $set: { username: newUsername, name: newName } },
},
{ ...actor, type: 'user' },
]);
});
it('should build event with only emails field', async () => {
const [user, updater, actor] = createUserAndUpdater({ ...createEmailsField() });
const store = new UserChangedAuditStore(actor);
const newEmailsField = createEmailsField();
updater.set('emails', newEmailsField.emails);
store.setOriginalUser(user as IUser);
store.setUpdateFilter(updater.getUpdateFilter());
const event = await store.commitAuditEvent();
expect(event).toEqual([
'user.changed',
{
user: { _id: user._id, username: user.username },
operation: { $set: { ...newEmailsField } },
user_data: { emails: user.emails },
},
{ ...actor, type: 'user' },
]);
});
it('should build event with every changed field', async () => {
const [user, updater, actor] = createUserAndUpdater({ ...createEmailsField(), active: false });
const changes = {
...createFakeUser(),
...createEmailsField(),
type: 'bot',
active: true,
};
Object.entries(changes).forEach(([key, value]: any) => {
updater.set(key, value);
});
updater.addToSet('roles', 'user');
updater.addToSet('roles', 'bot');
const store = new UserChangedAuditStore(actor);
store.setOriginalUser(user as IUser);
store.setUpdateFilter(updater.getUpdateFilter());
const event = await store.commitAuditEvent();
expect(event).toEqual([
'user.changed',
{
user: { _id: user._id, username: user.username },
user_data: user,
operation: {
$set: {
...changes,
},
$addToSet: {
roles: { $each: ['user', 'bot'] },
},
},
},
{
...actor,
type: 'user',
},
]);
});
it('should obfuscate sensitive fields', async () => {
const [user, updater, actor] = createUserAndUpdater({ ...createEmailsField(), ...createObfuscatedFields(false), active: false });
const store = new UserChangedAuditStore(actor);
const changes = {
...createFakeUser(),
...createEmailsField(),
...createObfuscatedFields(true),
type: 'bot',
active: true,
};
Object.entries(changes).forEach(([key, value]: any) => {
updater.set(key, value);
});
updater.addToSet('roles', 'user');
updater.addToSet('roles', 'bot');
store.setOriginalUser(user as IUser);
store.setUpdateFilter(updater.getUpdateFilter());
const event = await store.commitAuditEvent();
expect(event).toEqual([
'user.changed',
{
user: { _id: user._id, username: user.username },
user_data: { ...user, ...getObfuscatedFields(user.services?.email2fa as any) },
operation: {
$set: {
...changes,
...getObfuscatedFields(changes.services?.email2fa as any),
},
$addToSet: {
roles: { $each: ['user', 'bot'] },
},
},
},
{
...actor,
type: 'user',
},
]);
});
it('should obfuscate nested services', async () => {
const [user, updater, actor] = createUserAndUpdater({ ...createEmailsField(), ...createObfuscatedFields(false), active: false });
const store = new UserChangedAuditStore(actor);
updater.set('services.password.bcrypt', faker.string.uuid());
updater.set('services.resume.loginTokens', faker.string.uuid());
store.setOriginalUser(user as IUser);
store.setUpdateFilter(updater.getUpdateFilter());
const event = await store.commitAuditEvent();
expect(event).toEqual([
'user.changed',
{
user: { _id: user._id, username: user.username },
user_data: { services: { password: '****', resume: '****' } },
operation: { $set: { 'services.password.bcrypt': '****', 'services.resume.loginTokens': '****' } },
},
{ ...actor, type: 'user' },
]);
});
it('should obfuscate all services when they are set at once', async () => {
const [user, updater, actor] = createUserAndUpdater({ ...createEmailsField(), ...createObfuscatedFields(false), active: false });
const store = new UserChangedAuditStore(actor);
updater.set('services', { password: { bcrypt: faker.string.uuid() } });
store.setOriginalUser(user as IUser);
store.setUpdateFilter(updater.getUpdateFilter());
const event = await store.commitAuditEvent();
expect(event).toEqual([
'user.changed',
{
user: { _id: user._id, username: user.username },
user_data: { services: getObfuscatedServices(user.services?.email2fa as any) },
operation: { $set: { services: { password: '****' } } },
},
{ ...actor, type: 'user' },
]);
});
});

View File

@@ -0,0 +1,168 @@
import type { IAuditServerUserActor, IServerEvents, ExtractDataToParams, IUser } from '@rocket.chat/core-typings';
import { ServerEvents } from '@rocket.chat/models';
import type { UpdateFilter } from 'mongodb';
const userKeysToObfuscate = ['authorizedClients', 'e2e', 'inviteToken', 'oauth'];
const nestableKeysToObfuscate = ['services', 'password', 'bcrypt']; // ex: services.password.bcrypt
const obfuscateServices = (services: Record<string, any>): Record<string, any> => {
return Object.fromEntries(
Object.keys(services).map((key) => {
// Email 2FA is okay, only tells if it's enabled
if (key === 'email2fa') {
return [key, services[key]];
}
return [key, '****'];
}),
);
};
export class UserChangedAuditStore {
private originalUser: Partial<IUser> | undefined;
private updateFilter: UpdateFilter<IUser> | undefined;
private actor: IAuditServerUserActor;
constructor(actor: Omit<IAuditServerUserActor, 'type'>, type: IAuditServerUserActor['type'] = 'user') {
this.actor = { ...actor, type };
}
public setOriginalUser(user: Partial<IUser>) {
this.originalUser = user;
}
public setUpdateFilter(updateFilter: UpdateFilter<IUser>) {
this.updateFilter = Object.fromEntries(
Object.entries(updateFilter).map(([key, value]) => {
const obfuscatedValue = Object.entries(value).reduce((acc, [k, v]) => {
if (userKeysToObfuscate.includes(k)) {
return {
...acc,
[k]: '****',
};
}
// In case all services are set at once, we need to obfuscate them
if (k === 'services') {
return {
...acc,
[k]: obfuscateServices(v as Record<string, any>),
};
}
if (nestableKeysToObfuscate.some((key) => k.includes(key))) {
return {
...acc,
[k]: '****',
};
}
return { ...acc, [k]: v };
}, {});
return [key, obfuscatedValue];
}),
);
}
private filterUserChangedProperties(originalUser: Partial<IUser>, updateFilter: UpdateFilter<IUser>): Partial<IUser> {
if (Object.keys(updateFilter).length === 0) {
return {};
}
// extract keys from updateFilter (keys are nested in $set, $unset, $inc, etc)
const updateFilterKeys: string[] = Object.values(updateFilter).reduce((acc, current) => {
const keys = Object.keys(current);
if (keys.length === 0) {
return acc;
}
return [...acc, ...keys];
}, []);
return Object.entries(originalUser).reduce((acc, [key, value]) => {
if (!updateFilterKeys.some((k) => k.includes(key))) {
return acc;
}
if (userKeysToObfuscate.includes(key)) {
return {
...acc,
[key]: '****',
};
}
if (key === 'services') {
// In case all services are set at once we should
// obfuscate all user services, because they'll all change
if (updateFilterKeys.some((k) => k === 'services')) {
return {
...acc,
[key]: obfuscateServices(value as Record<string, any>),
};
}
const changedNestedServices = updateFilterKeys
.filter((k) => k.includes(key) && k.includes('.'))
.map((serviceKey) => {
// service key can be nested with dot notation
// ex: services.password.bcrypt
const serviceKeyParts = serviceKey.split('.');
return [serviceKeyParts[1], value[serviceKey as keyof typeof value]];
})
.filter(Boolean);
if (!changedNestedServices.length) {
return acc;
}
return {
...acc,
[key]: obfuscateServices(Object.fromEntries(changedNestedServices) as Record<string, any>),
};
}
return {
...acc,
[key]: value,
};
}, {});
}
private getEventData(
originalUser: Partial<IUser>,
updateFilter: UpdateFilter<IUser>,
): ExtractDataToParams<IServerEvents['user.changed']> {
const userData = this.filterUserChangedProperties(originalUser, updateFilter);
return {
user: { _id: originalUser._id || '', username: originalUser.username },
user_data: userData,
operation: updateFilter,
};
}
private buildEvent(): ['user.changed', ExtractDataToParams<IServerEvents['user.changed']>, IAuditServerUserActor] {
if (!this.updateFilter) {
throw new Error('UserChangedAuditStore - Updater is undefined');
}
if (!this.originalUser) {
throw new Error('UserChangedAuditStore - OriginalUser is undefined');
}
const eventData = this.getEventData(this.originalUser, this.updateFilter);
if (Object.keys(eventData.user_data).length === 0 || Object.keys(eventData.operation).length === 0) {
// UpdaterImpl throws an error when trying to build the filter if no changes are detected
// so we should never get here
throw new Error('UserChangedAuditStore - No changes detected');
}
return ['user.changed', eventData, this.actor];
}
public async commitAuditEvent() {
const event = this.buildEvent();
return ServerEvents.createAuditServerEvent(...event);
}
}

View File

@@ -0,0 +1,38 @@
import { MeteorError } from '@rocket.chat/core-services';
import { Accounts } from 'meteor/accounts-base';
import { handleIdentityToken } from './handleIdentityToken';
import { settings } from '../../../settings';
import { CustomOAuth } from '../custom-oauth/custom_oauth_server';
export class AppleCustomOAuth extends CustomOAuth {
override async getIdentity(_accessToken: string, query: Record<string, any>): Promise<any> {
const { id_token: identityToken, user: userStr = '' } = query;
let usrObj = {} as any;
try {
usrObj = JSON.parse(userStr);
} catch (e) {
// ignore
}
try {
const clientId = settings.get<string>('Accounts_OAuth_Apple_id') || '';
const serviceData = await handleIdentityToken(identityToken, clientId);
if (usrObj?.name) {
serviceData.name = `${usrObj.name.firstName}${usrObj.name.middleName ? ` ${usrObj.name.middleName}` : ''}${
usrObj.name.lastName ? ` ${usrObj.name.lastName}` : ''
}`;
}
return serviceData;
} catch (error: any) {
return {
type: 'apple',
error: new MeteorError(Accounts.LoginCancelledError.numericError, error.message),
};
}
}
}

View File

@@ -0,0 +1,125 @@
import { createPrivateKey, sign } from 'node:crypto';
import { ServiceConfiguration } from 'meteor/service-configuration';
import { AppleCustomOAuth } from './AppleCustomOAuth';
import { config } from '../../../../app/apple/lib/config';
import { settings } from '../../../settings';
new AppleCustomOAuth('apple', config);
const toBase64Url = (obj: Record<string, any>) => Buffer.from(JSON.stringify(obj)).toString('base64url');
function generateAppleClientSecret(header: Record<string, any>, payload: Record<string, any>, privateKeyString: string): string {
const headerB64 = toBase64Url(header);
const payloadB64 = toBase64Url(payload);
const dataToSign = `${headerB64}.${payloadB64}`;
const privateKey = createPrivateKey({
key: privateKeyString,
format: 'pem',
type: 'pkcs8',
});
const signature = sign('sha256', Buffer.from(dataToSign), {
key: privateKey,
dsaEncoding: 'ieee-p1363',
});
const signatureB64 = signature.toString('base64url');
return `${dataToSign}.${signatureB64}`;
}
settings.watchMultiple(
[
'Accounts_OAuth_Apple',
'Accounts_OAuth_Apple_id',
'Accounts_OAuth_Apple_secretKey',
'Accounts_OAuth_Apple_iss',
'Accounts_OAuth_Apple_kid',
],
async ([enabled, clientId, serverSecret, iss, kid]) => {
if (!enabled) {
return ServiceConfiguration.configurations.removeAsync({
service: 'apple',
});
}
const [normalizedClientId, normalizedServerSecret, normalizedIss, normalizedKid] = [clientId, serverSecret, iss, kid].map((value) =>
typeof value === 'string' ? value.trim() : '',
);
const hasAllFields = [normalizedClientId, normalizedServerSecret, normalizedIss, normalizedKid].every(Boolean);
// Hide web button if settings are incomplete, but preserve mobile-only setup if enabled.
if (!hasAllFields) {
await ServiceConfiguration.configurations.upsertAsync(
{
service: 'apple',
},
{
$set: {
showButton: false,
enabled: settings.get('Accounts_OAuth_Apple'),
},
},
);
return;
}
const HEADER = {
kid: normalizedKid,
alg: 'ES256',
};
const now = new Date();
const exp = new Date();
exp.setMonth(exp.getMonth() + 5);
try {
const secret = generateAppleClientSecret(
HEADER,
{
iss: normalizedIss,
iat: Math.floor(now.getTime() / 1000),
exp: Math.floor(exp.getTime() / 1000),
aud: 'https://appleid.apple.com',
sub: normalizedClientId,
},
normalizedServerSecret,
);
await ServiceConfiguration.configurations.upsertAsync(
{
service: 'apple',
},
{
$set: {
showButton: true,
secret,
enabled: settings.get('Accounts_OAuth_Apple'),
loginStyle: 'popup',
clientId: normalizedClientId,
buttonColor: '#000',
buttonLabelColor: '#FFF',
},
},
);
} catch (error) {
console.error('Failed to configure Apple OAuth service', error);
await ServiceConfiguration.configurations.upsertAsync(
{
service: 'apple',
},
{
$set: {
showButton: false,
enabled: settings.get('Accounts_OAuth_Apple'),
},
},
);
}
},
);

View File

@@ -0,0 +1,178 @@
import { MeteorError } from '@rocket.chat/core-services';
import { Users } from '@rocket.chat/models';
import express from 'express';
import { Accounts } from 'meteor/accounts-base';
import { ServiceConfiguration } from 'meteor/service-configuration';
import passport from 'passport';
import { Strategy as AppleStrategy } from 'passport-apple';
import type { Profile } from 'passport-apple';
import { AppleCustomOAuth } from './AppleCustomOAuth';
import { handleIdentityToken } from './handleIdentityToken';
import { config } from '../../../../app/apple/lib/config';
import { oAuthRouter } from '../../../configuration/configurePassport';
import { settings } from '../../../settings';
import { allowPassportOAuthMiddleware } from '../../oauth/allowPassportOAuthMiddleware';
import { passportOAuthCallback } from '../../oauth/passportOAuthCallback';
import { removeOAuthRoutes } from '../../oauth/removeOAuthRoutes';
new AppleCustomOAuth('apple', config);
type RequestWithAppleProfile = {
appleProfile?: {
name?: {
firstName?: string;
middleName?: string;
lastName?: string;
};
};
};
function isRequestWithAppleProfile(req: object): req is RequestWithAppleProfile {
if (
'appleProfile' in req &&
req.appleProfile &&
typeof req.appleProfile === 'object' &&
'name' in req.appleProfile &&
req.appleProfile.name &&
typeof req.appleProfile.name === 'object'
) {
return true;
}
return false;
}
settings.watchMultiple(
[
'Accounts_OAuth_Apple',
'Accounts_OAuth_Apple_id',
'Accounts_OAuth_Apple_secretKey',
'Accounts_OAuth_Apple_iss',
'Accounts_OAuth_Apple_kid',
'Accounts_OAuth_Use_Modern_Flow',
],
async ([enabled, clientId, serverSecret, iss, kid, useModernFlow]) => {
passport.unuse('apple');
removeOAuthRoutes('apple');
if (!useModernFlow) {
return;
}
if (!enabled) {
return ServiceConfiguration.configurations.removeAsync({
service: 'apple',
});
}
// if everything is empty but Apple login is enabled, don't show the login button
if (!clientId && !serverSecret && !iss && !kid) {
await ServiceConfiguration.configurations.upsertAsync(
{
service: 'apple',
},
{
$set: {
showButton: false,
enabled: settings.get('Accounts_OAuth_Apple'),
},
},
);
return;
}
if (typeof clientId !== 'string' || !clientId) {
return ServiceConfiguration.configurations.removeAsync({
service: 'apple',
});
}
passport.use(
'apple',
new AppleStrategy(
{
clientID: clientId,
teamID: settings.get<string>('Accounts_OAuth_Apple_iss'),
keyID: settings.get<string>('Accounts_OAuth_Apple_kid'),
privateKeyString: settings.get<string>('Accounts_OAuth_Apple_secretKey').replace(/\\n/g, '\n'),
callbackURL: `${settings.get<string>('Site_Url').replace(/\/$/, '')}/_oauth/apple`,
scope: ['name', 'email'],
passReqToCallback: true,
state: false,
},
async (req, accessToken: string, refreshToken: string, idToken: string, _profile: Profile, done) => {
try {
const serviceData = await handleIdentityToken(idToken, clientId);
if (isRequestWithAppleProfile(req)) {
const appleName = req.appleProfile?.name;
serviceData.name = [appleName?.firstName, appleName?.middleName, appleName?.lastName].filter(Boolean).join(' ');
}
const user = await Accounts.updateOrCreateUserFromExternalService(
'apple',
{
accessToken,
refreshToken,
...serviceData,
},
{},
);
if (!user?.userId || typeof user?.userId !== 'string') {
return done(new Error('User not found'));
}
const userFromDB = await Users.findOneById(user.userId);
if (!userFromDB) {
return done(new Error('User not found'));
}
return done(null, userFromDB);
} catch (error: any) {
done(error);
return {
type: 'apple',
error: new MeteorError(Accounts.LoginCancelledError.numericError, error.message),
};
}
},
),
);
const callbackHandler = [
allowPassportOAuthMiddleware('apple'),
express.urlencoded({ extended: true }),
passport.authenticate('apple', { failWithError: true, session: true, keepSessionInfo: true }),
passportOAuthCallback(settings.get<string>('Site_Url').replace(/\/$/, '')),
];
oAuthRouter.get(
'/oauth/apple',
allowPassportOAuthMiddleware('apple'),
(req, _res, next) => {
const { loginClient } = req.query;
if (loginClient === 'mobile' || loginClient === 'desktop') {
req.session.loginClient = loginClient;
req.session.save(() => {
next();
});
} else {
//delete stale value from previous sessions if any
delete req.session.loginClient;
next();
}
},
passport.authenticate('apple', {
scope: ['name', 'email'],
}),
);
oAuthRouter
.route('/_oauth/apple')
.post(...callbackHandler)
.get(...callbackHandler);
},
);

View File

@@ -0,0 +1,133 @@
import { generateKeyPairSync, sign } from 'node:crypto';
import { serverFetch } from '@rocket.chat/server-fetch';
import { Response } from 'node-fetch';
import { handleIdentityToken } from './handleIdentityToken';
jest.mock('@rocket.chat/server-fetch', () => ({
serverFetch: jest.fn(),
}));
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
});
const jwkPublicKey = publicKey.export({ format: 'jwk' });
const toBase64Url = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url');
describe('handleIdentityToken', () => {
const mockClientId = 'com.yourcompany.app';
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers().setSystemTime(new Date('2024-01-01T00:00:00Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should throw an error if the token has the wrong audience', async () => {
const header = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' });
const payload = toBase64Url({
iss: 'https://appleid.apple.com',
aud: 'wrong.client.id',
exp: Math.floor(Date.now() / 1000) + 3600,
sub: 'user123',
});
const mockToken = `${header}.${payload}.dummySignature`;
await expect(handleIdentityToken(mockToken, mockClientId)).rejects.toThrow('identityToken is not a valid Apple JWT or has expired');
});
it('should successfully validate a valid token', async () => {
const headerB64 = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' });
const payloadB64 = toBase64Url({
iss: 'https://appleid.apple.com',
aud: mockClientId,
exp: Math.floor(Date.now() / 1000) + 3600,
sub: 'user123',
});
const signatureBytes = sign('RSA-SHA256', Buffer.from(`${headerB64}.${payloadB64}`), privateKey);
const signatureB64 = signatureBytes.toString('base64url');
const validMockToken = `${headerB64}.${payloadB64}.${signatureB64}`;
if (!jwkPublicKey.n || !jwkPublicKey.e) {
throw new Error('Generated test key is missing modulus or exponent');
}
const mockJwksPayload = {
keys: [
{
kty: 'RSA',
kid: 'mock-key-id',
use: 'sig',
alg: 'RS256',
n: jwkPublicKey.n,
e: jwkPublicKey.e,
},
],
};
jest.mocked(serverFetch).mockResolvedValue(
new Response(JSON.stringify(mockJwksPayload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const result = await handleIdentityToken(validMockToken, mockClientId);
expect(result.id).toBe('user123');
expect(result.iss).toBe('https://appleid.apple.com');
});
it('should accept default mobile audience when client id setting is empty', async () => {
const headerB64 = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' });
const payloadB64 = toBase64Url({
iss: 'https://appleid.apple.com',
aud: 'chat.rocket.ios',
exp: Math.floor(Date.now() / 1000) + 3600,
sub: 'user123',
});
const signatureBytes = sign('RSA-SHA256', Buffer.from(`${headerB64}.${payloadB64}`), privateKey);
const signatureB64 = signatureBytes.toString('base64url');
const validMockToken = `${headerB64}.${payloadB64}.${signatureB64}`;
if (!jwkPublicKey.n || !jwkPublicKey.e) {
throw new Error('Generated test key is missing modulus or exponent');
}
const mockJwksPayload = {
keys: [
{
kty: 'RSA',
kid: 'mock-key-id',
use: 'sig',
alg: 'RS256',
n: jwkPublicKey.n,
e: jwkPublicKey.e,
},
],
};
jest.mocked(serverFetch).mockResolvedValue(
new Response(JSON.stringify(mockJwksPayload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const result = await handleIdentityToken(validMockToken, '');
expect(result.id).toBe('user123');
expect(result.aud).toBe('chat.rocket.ios');
});
});

View File

@@ -0,0 +1,166 @@
import { createPublicKey, verify } from 'node:crypto';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
type AppleJWK = {
kty: string;
kid: string;
use: string;
alg: string;
n: string;
e: string;
};
type AppleJWTPayload = {
iss: string;
sub: string;
aud: string | string[];
exp: number;
iat: number;
email?: string;
email_verified?: string | boolean;
is_private_email?: string | boolean;
};
const DEFAULT_APPLE_AUDIENCES = ['chat.rocket.ios'];
let cachedKeys: AppleJWK[] | null = null;
let lastFetchTime = 0;
const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
async function getApplePublicKeys(forceRefresh = false): Promise<AppleJWK[]> {
const now = Date.now();
if (!forceRefresh && cachedKeys && now - lastFetchTime < CACHE_TTL_MS) {
return cachedKeys;
}
try {
const response = await fetch('https://appleid.apple.com/auth/keys', {
method: 'GET',
// SECURITY: Hardcoded URL, no SSRF protection needed
ignoreSsrfValidation: true,
});
if (!response.ok) {
throw new Error(`Failed to fetch Apple keys: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as { keys: AppleJWK[] };
cachedKeys = data.keys;
lastFetchTime = now;
return cachedKeys;
} catch (error) {
if (cachedKeys) {
console.warn('Failed to refresh Apple public keys, using stale cache', error);
return cachedKeys;
}
throw new Error('Could not retrieve Apple public keys', { cause: error });
}
}
function decodeBase64Url(str: string): string {
return Buffer.from(str, 'base64url').toString('utf8');
}
async function verifyAppleJWT(
headerB64: string,
payloadB64: string,
signatureB64: string,
clientId: string,
): Promise<AppleJWTPayload | null> {
const header = JSON.parse(decodeBase64Url(headerB64));
const payload = JSON.parse(decodeBase64Url(payloadB64)) as AppleJWTPayload;
const nowInSeconds = Math.floor(Date.now() / 1000);
if (payload.exp < nowInSeconds) {
console.error('Apple JWT has expired');
return null;
}
if (payload.iss !== 'https://appleid.apple.com') {
console.error('Invalid issuer. Expected https://appleid.apple.com');
return null;
}
const audArray = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
const configuredAudiences = clientId
.split(',')
.map((id) => id.trim())
.filter(Boolean);
const allowedAudiences = Array.from(new Set([...DEFAULT_APPLE_AUDIENCES, ...configuredAudiences]));
const isAudienceValid = allowedAudiences.some((allowedAud) => audArray.includes(allowedAud));
if (!isAudienceValid) {
console.error(`Invalid audience. Expected one of: ${allowedAudiences.join(', ')}`);
return null;
}
let applePublicKeys = await getApplePublicKeys();
let keyData = applePublicKeys.find((k) => k.kid === header.kid);
if (!keyData) {
applePublicKeys = await getApplePublicKeys(true); // Force refresh
keyData = applePublicKeys.find((k) => k.kid === header.kid);
if (!keyData) {
console.error('Matching Key ID (kid) not found in Apple JWKS');
return null;
}
}
try {
const publicKey = createPublicKey({
key: {
kty: keyData.kty,
n: keyData.n,
e: keyData.e,
},
format: 'jwk',
});
const isSignatureValid = verify(
'RSA-SHA256',
Buffer.from(`${headerB64}.${payloadB64}`),
publicKey,
Buffer.from(signatureB64, 'base64url'),
);
return isSignatureValid ? payload : null;
} catch (error) {
console.error('Cryptographic signature verification failed:', error);
return null;
}
}
export async function handleIdentityToken(identityToken: string, clientId: string): Promise<Record<string, any>> {
const parts = identityToken.split('.');
if (parts.length !== 3) {
throw new Error('Malformed identityToken: JWT must have 3 parts');
}
const [headerB64, payloadB64, signatureB64] = parts;
const payload = await verifyAppleJWT(headerB64, payloadB64, signatureB64, clientId);
if (!payload) {
throw new Error('identityToken is not a valid Apple JWT or has expired');
}
if (!payload.sub) {
throw new Error('Insufficient data: Missing subject (sub) in auth response token');
}
const serviceData = {
id: payload.sub,
...payload,
};
return serviceData;
}

View File

@@ -0,0 +1,124 @@
import { Accounts } from 'meteor/accounts-base';
import { handleIdentityToken } from './handleIdentityToken';
import { settings } from '../../../settings';
jest.mock(
'meteor/accounts-base',
() => ({
Accounts: {
registerLoginHandler: jest.fn(),
updateOrCreateUserFromExternalService: jest.fn(),
LoginCancelledError: { numericError: 400 },
},
}),
{ virtual: true },
);
jest.mock(
'meteor/meteor',
() => ({
Meteor: {
Error: class extends Error {
constructor(
public error: number,
public reason: string,
) {
super(reason);
}
},
},
}),
{ virtual: true },
);
jest.mock('../../../settings', () => ({
settings: {
get: jest.fn(),
},
}));
jest.mock('./handleIdentityToken', () => ({
handleIdentityToken: jest.fn(),
}));
describe('Apple OAuth loginHandler', () => {
let loginHandlerCallback: Parameters<typeof Accounts.registerLoginHandler>[1];
beforeAll(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('./loginHandler');
loginHandlerCallback = jest.mocked(Accounts.registerLoginHandler).mock.calls[0][1];
});
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(settings.get).mockImplementation((key) => {
if (key === 'Accounts_OAuth_Apple') return true;
if (key === 'Accounts_OAuth_Apple_id') return 'com.yourcompany.app';
return null;
});
});
it('should not use the client-provided email if Apple does not provide one', async () => {
jest.mocked(handleIdentityToken).mockResolvedValue({
id: 'apple-sub-123',
});
const maliciousLoginRequest = {
identityToken: 'valid.token.without_email',
email: 'alice@email.tld',
fullName: { givenName: 'Alice', familyName: 'Sender' },
};
jest.mocked(Accounts.updateOrCreateUserFromExternalService).mockResolvedValue({ userId: 'new-user-id' });
await loginHandlerCallback(maliciousLoginRequest);
expect(Accounts.updateOrCreateUserFromExternalService).toHaveBeenCalledWith(
'apple',
{ id: 'apple-sub-123' },
{ profile: { name: 'Alice Sender' } },
);
});
it('should successfully pass the email if Apple natively provides it in the signed JWT', async () => {
jest.mocked(handleIdentityToken).mockResolvedValue({
id: 'apple-sub-123',
email: 'legit@email.tld',
});
const legitLoginRequest = {
identityToken: 'valid.token.with_email',
fullName: { givenName: 'John', familyName: 'Doe' },
};
jest.mocked(Accounts.updateOrCreateUserFromExternalService).mockResolvedValue({ userId: 'user-id' });
await loginHandlerCallback(legitLoginRequest);
expect(Accounts.updateOrCreateUserFromExternalService).toHaveBeenCalledWith(
'apple',
{ id: 'apple-sub-123', email: 'legit@email.tld' },
{ profile: { name: 'John Doe' } },
);
});
it('should pass empty client id to token validation when setting is not configured', async () => {
jest.mocked(settings.get).mockImplementation((key) => {
if (key === 'Accounts_OAuth_Apple') return true;
if (key === 'Accounts_OAuth_Apple_id') return '';
return null;
});
jest.mocked(handleIdentityToken).mockResolvedValue({ id: 'apple-sub-123' });
jest.mocked(Accounts.updateOrCreateUserFromExternalService).mockResolvedValue({ userId: 'user-id' });
await loginHandlerCallback({
identityToken: 'valid.token.with_mobile_default_audience',
fullName: { givenName: 'Mobile', familyName: 'User' },
});
expect(handleIdentityToken).toHaveBeenCalledWith('valid.token.with_mobile_default_audience', '');
});
});

View File

@@ -0,0 +1,46 @@
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import { handleIdentityToken } from './handleIdentityToken';
import { settings } from '../../../settings';
Accounts.registerLoginHandler('apple', async (loginRequest) => {
if (!loginRequest.identityToken) {
return;
}
if (!settings.get('Accounts_OAuth_Apple')) {
return;
}
const { identityToken, fullName } = loginRequest;
try {
const clientId = settings.get<string>('Accounts_OAuth_Apple_id') || '';
const serviceData = await handleIdentityToken(identityToken, clientId);
const profile: { name?: string } = {};
const { givenName, familyName } = fullName || {};
if (givenName && familyName) {
profile.name = `${givenName} ${familyName}`;
}
const result = await Accounts.updateOrCreateUserFromExternalService('apple', serviceData, { profile });
if (result?.userId === undefined) {
return {
type: 'apple',
error: new Meteor.Error(Accounts.LoginCancelledError.numericError, 'User creation failed from Apple response token'),
};
}
return result;
} catch (error: any) {
return {
type: 'apple',
error: new Meteor.Error(Accounts.LoginCancelledError.numericError, error.message),
};
}
});

View File

@@ -0,0 +1,424 @@
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<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 (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?.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());
}
});
});

View File

@@ -0,0 +1,3 @@
import { Logger } from '@rocket.chat/logger';
export const logger = new Logger('CROWD');

View File

@@ -0,0 +1,7 @@
# Custom OAuth
An implementation of the OAuth2 flow. It works with your own private OAuth2 server instance.
This software is supplied courtesy of the [Rocket.Chat](https://rocket.chat/) open source communications platform.
See the [project page](https://www.meteor.com/accounts) on Meteor Accounts for more details.

View File

@@ -0,0 +1,403 @@
import { LDAP } from '@rocket.chat/core-services';
import type { IUser, OAuthConfiguration } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { Users } from '@rocket.chat/models';
import { isAbsoluteURL } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import type { DoneCallback } from 'passport';
import type { VerifyFunction, StrategyOptions } from 'passport-oauth2';
import { Strategy } from 'passport-oauth2';
import { normalizers, fromTemplate, renameInvalidProperties } from './transform_helpers';
import { client } from '../../../database/utils';
import { settings } from '../../../settings/cached';
import { callbacks } from '../../callbacks';
import { notifyOnUserChange } from '../../notifyListener';
import { saveUserIdentity } from '../../users/saveUserIdentity';
const logger = new Logger('CustomOAuth');
const BeforeUpdateOrCreateUserFromExternalService = new Map<
string,
(serviceName: string, serviceData: Record<string, any>) => Promise<void>
>();
const CustomOAuthInstances = new Map<string, CustomOAuthStrategy>();
export class CustomOAuthStrategy extends Strategy {
options: StrategyOptions;
config: OAuthConfiguration & { clientSecret: string };
identityPath: string;
emailPath: string;
tokenSentVia: string;
identityTokenSentVia: string;
keyField: string;
usernameField: string;
emailField: string;
nameField: string;
avatarField: string;
mergeUsers: boolean;
mergeUsersDistinctServices: boolean;
rolesClaim: string;
accessTokenParam: string;
serverURL: string;
tokenPath: string;
constructor(name: string, config: OAuthConfiguration & { clientSecret: string }, verify: VerifyFunction) {
if (!config.serverURL || typeof config.serverURL !== 'string') {
throw new Meteor.Error('customOAuth: serverURL is required and must be String');
}
const options: StrategyOptions = {
authorizationURL: config.serverURL + config.authorizePath,
tokenURL: config.serverURL + config.tokenPath,
clientID: config.clientId,
clientSecret: config.clientSecret,
callbackURL: `${settings.get<string>('Site_Url').replace(/\/$/, '')}/_oauth/${name}`,
state: true,
pkce: config.pkce ?? true,
scope: config.scope,
};
super(options, verify);
this.serverURL = config.serverURL;
this.tokenPath = config.tokenPath || '/oauth/token';
this.identityPath = config.identityPath || '/me';
this.tokenSentVia = config.tokenSentVia;
this.identityTokenSentVia = config.identityTokenSentVia;
this.keyField = config.keyField;
this.usernameField = config.usernameField?.trim();
this.emailField = config.emailField?.trim();
this.nameField = config.nameField?.trim();
this.avatarField = config.avatarField?.trim();
this.mergeUsers = !!config.mergeUsers;
this.mergeUsersDistinctServices = !!config.mergeUsersDistinctServices;
this.rolesClaim = config.rolesClaim || 'roles';
this.accessTokenParam = config.accessTokenParam || 'access_token';
if (this.identityTokenSentVia == null || this.identityTokenSentVia === 'default') {
this.identityTokenSentVia = this.tokenSentVia;
}
if (!isAbsoluteURL(this.tokenPath)) {
this.tokenPath = this.serverURL + this.tokenPath;
}
if (!isAbsoluteURL(this.identityPath)) {
this.identityPath = this.serverURL + this.identityPath;
}
if (this.emailPath && !isAbsoluteURL(this.emailPath)) {
this.emailPath = this.serverURL + this.emailPath;
}
if (this.identityTokenSentVia === 'header') {
this._oauth2.useAuthorizationHeaderforGET(true);
}
if (config.accessTokenParam && config.accessTokenParam !== 'access_token') {
this._oauth2.setAccessTokenName(config.accessTokenParam);
}
if (config.addAutopublishFields && typeof config.addAutopublishFields === 'object') {
Accounts.addAutopublishFields(config.addAutopublishFields);
}
this.name = name;
this.options = options;
this.config = config;
this.addHookToProcessUser();
}
getUsername(data: Record<string, any>) {
try {
const value = fromTemplate(this.usernameField, data);
if (!value) {
logger.debug({ msg: 'Username field not found in data', usernameField: this.usernameField, data });
throw new Meteor.Error('field_not_found', `Username field "${this.usernameField}" not found in data`);
}
return value as string;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract username', { cause: error });
}
}
getEmail(data: Record<string, any>) {
try {
const value = fromTemplate(this.emailField, data);
if (!value) {
logger.debug({ msg: 'Email field not found in data', emailField: this.emailField, data });
throw new Meteor.Error('field_not_found', `Email field "${this.emailField}" not found in data`);
}
return value as string;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract email', { cause: error });
}
}
getCustomName(data: Record<string, any>) {
try {
const value = fromTemplate(this.nameField, data);
if (!value) {
return this.getName(data);
}
return value as string;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract custom name', { cause: error });
}
}
getAvatarUrl(data: Record<string, any>) {
try {
const value = fromTemplate(this.avatarField, data);
if (!value) {
logger.debug({ msg: 'Avatar field not found in data', avatarField: this.avatarField, data });
}
return value as string;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract avatar url', { cause: error });
}
}
getName(identity: Record<string, any>): string {
const name = (identity.name ||
identity.username ||
identity.nickname ||
identity.CharacterName ||
identity.userName ||
identity.preferred_username ||
identity.user?.name) as string;
return name;
}
normalizeIdentity(identity: Record<string, any>) {
if (identity) {
for (const normalizer of Object.values(normalizers)) {
const result = normalizer(identity);
if (result) {
identity = result;
}
}
}
if (this.usernameField) {
identity.username = this.getUsername(identity);
}
if (this.emailField) {
identity.email = this.getEmail(identity);
}
if (this.avatarField) {
identity.avatarUrl = this.getAvatarUrl(identity);
}
if (this.nameField) {
identity.name = this.getCustomName(identity);
} else {
identity.name = this.getName(identity);
}
return renameInvalidProperties(identity);
}
override userProfile(accessToken: string, done: DoneCallback) {
if (!this.identityPath) {
return done(new Error(`identityPath is required for ${this.name} custom oauth`));
}
this._oauth2.get(this.identityPath, accessToken, (err, body, res) => {
if (err) {
return done(JSON.stringify(err));
}
if ((res && res.statusCode !== 200) || !body) {
return done(new Error(`Failed to fetch identity from ${this.name} at ${this.identityPath}}`));
}
try {
const result = JSON.parse(typeof body === 'string' ? body : body.toString());
const normalizedIdentity = this.normalizeIdentity(result);
//Nextcloud URL needed on addWebdavServer
normalizedIdentity.serverURL = this.serverURL;
return done(null, normalizedIdentity);
} catch (e) {
return done(new Error(`Failed to parse identity from ${this.name} at ${this.identityPath}. ${e}`));
}
});
}
addHookToProcessUser() {
BeforeUpdateOrCreateUserFromExternalService.set(this.name, async (serviceName, serviceData) => {
if (serviceName !== this.name) {
return;
}
if (serviceData.username) {
let user: IUser | null = null;
if (this.keyField === 'username') {
user = this.mergeUsersDistinctServices
? await Users.findOneByUsernameIgnoringCase(serviceData.username)
: await Users.findOneByUsernameAndServiceNameIgnoringCase(serviceData.username, serviceData.id, serviceName);
} else if (this.keyField === 'email') {
user = this.mergeUsersDistinctServices
? await Users.findOneByEmailAddress(serviceData.email)
: await Users.findOneByEmailAddressAndServiceNameIgnoringCase(serviceData.email, serviceData.id, serviceName);
}
if (!user) {
return;
}
await callbacks.run('afterProcessOAuthUser', { serviceName, serviceData, user });
// User already created or merged and has identical name as before
if (
user.services?.[serviceName as keyof NonNullable<IUser['services']>] &&
user.services[serviceName as keyof NonNullable<IUser['services']>].id === serviceData.id &&
user.name === serviceData.name &&
(this.keyField === 'email' || !serviceData.email || user.emails?.find(({ address }) => address === serviceData.email))
) {
return;
}
if (this.mergeUsers !== true) {
throw new Meteor.Error('CustomOAuth', `User with username ${user.username} already exists`);
}
const serviceIdKey = `services.${serviceName}.id`;
const successCallbacks = [
async () => {
const updatedUser = await Users.findOneById(user._id, { projection: { name: 1, emails: 1, [serviceIdKey]: 1 } });
if (updatedUser) {
const { _id, ...diff } = updatedUser;
void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff });
}
},
];
const session = client.startSession();
try {
// Extend the session to match the ExtendedSession type expected by saveUserIdentity
Object.assign(session, {
onceSuccesfulCommit: (cb: () => Promise<void>) => {
successCallbacks.push(cb);
},
});
session.startTransaction();
const updater = Users.getUpdater();
if (this.keyField === 'username' && serviceData.email) {
updater.set('emails', [{ address: serviceData.email, verified: true }]);
}
updater.set(serviceIdKey as keyof IUser['services'], serviceData.id);
await saveUserIdentity({
_id: user._id,
name: serviceData.name,
updater,
session,
updateUsernameInBackground: true,
// Username needs to be included otherwise the name won't be updated in some collections
username: user.username,
});
await Users.updateFromUpdater({ _id: user._id }, updater, { session });
await session.commitTransaction();
} catch (e) {
await session.abortTransaction();
throw e;
} finally {
await session.endSession();
}
void Promise.allSettled(successCallbacks.map((cb) => cb()));
}
});
CustomOAuthInstances.set(this.name, this);
}
}
Accounts.validateNewUser((user: IUser & { email: string }) => {
for (const [name, instance] of CustomOAuthInstances) {
const service = user.services?.[name as keyof NonNullable<IUser['services']>];
if (!service?.id) {
continue;
}
if (instance.usernameField) {
user.username = service.username;
}
if (instance.emailField) {
user.email = service.email;
}
if (instance.nameField) {
user.name = service.name;
}
}
return true;
});
const { updateOrCreateUserFromExternalService } = Accounts;
Accounts.updateOrCreateUserFromExternalService = async function (...args) {
for (const hook of BeforeUpdateOrCreateUserFromExternalService.values()) {
await hook.apply(this, args as unknown as [string, Record<string, any>]);
}
const [serviceName, serviceData] = args;
const user = await updateOrCreateUserFromExternalService.apply(this, args);
if (!user?.userId) {
return undefined;
}
const fullUser = await Users.findOneById(user.userId as string);
if (!fullUser) {
return undefined;
}
if (settings.get('LDAP_Update_Data_On_OAuth_Login') && fullUser.username) {
await LDAP.loginAuthenticatedUserRequest(fullUser.username);
}
await callbacks.run('afterValidateNewOAuthUser', {
identity: serviceData,
serviceName,
user: fullUser,
});
return user;
};

View File

@@ -0,0 +1,7 @@
export class CustomOAuth {
constructor(name: string, options: Record<string, any>);
getIdentity(accessToken: string, query: Record<string, any>): any;
configure(options: Record<string, any>): any;
}

View File

@@ -0,0 +1,554 @@
import { LDAP } from '@rocket.chat/core-services';
import { Logger } from '@rocket.chat/logger';
import { Users } from '@rocket.chat/models';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { isAbsoluteURL } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { OAuth } from 'meteor/oauth';
import { ServiceConfiguration } from 'meteor/service-configuration';
import _ from 'underscore';
import { normalizers, fromTemplate, renameInvalidProperties } from './transform_helpers';
import { client } from '../../../database/utils';
import { settings } from '../../../settings';
import { callbacks } from '../../callbacks';
import { notifyOnUserChange } from '../../notifyListener';
import { saveUserIdentity } from '../../users/saveUserIdentity';
import { registerAccessTokenService } from '../oauth/oauth';
const logger = new Logger('CustomOAuth', { redact: ['options.clientSecret'] });
const Services = {};
const BeforeUpdateOrCreateUserFromExternalService = [];
/**
* @deprecated in favor of new Passport OAuth implementation.
*/
export class CustomOAuth {
constructor(name, options) {
logger.debug({ msg: 'Init CustomOAuth', name, options });
this.name = name;
if (!Match.test(this.name, String)) {
throw new Meteor.Error('CustomOAuth: Name is required and must be String');
}
if (Services[this.name]) {
Services[this.name].configure(options);
return;
}
Services[this.name] = this;
this.configure(options);
this.userAgent = 'Meteor';
if (Meteor.release) {
this.userAgent += `/${Meteor.release}`;
}
Accounts.oauth.registerService(this.name);
this.registerService();
this.addHookToProcessUser();
this.registerAccessTokenService(this.name, this.accessTokenParam);
}
configure(options) {
if (!Match.test(options, Object)) {
throw new Meteor.Error('CustomOAuth: Options is required and must be Object');
}
if (!Match.test(options.serverURL, String)) {
throw new Meteor.Error('CustomOAuth: Options.serverURL is required and must be String');
}
if (!Match.test(options.tokenPath, String)) {
options.tokenPath = '/oauth/token';
}
if (!Match.test(options.identityPath, String)) {
options.identityPath = '/me';
}
if (!Match.test(options.accessTokenParam, String)) {
options.accessTokenParam = 'access_token';
}
this.serverURL = options.serverURL;
this.tokenPath = options.tokenPath;
this.identityPath = options.identityPath;
this.emailPath = options.emailPath;
this.tokenSentVia = options.tokenSentVia;
this.identityTokenSentVia = options.identityTokenSentVia;
this.keyField = options.keyField;
this.usernameField = (options.usernameField || '').trim();
this.emailField = (options.emailField || '').trim();
this.nameField = (options.nameField || '').trim();
this.avatarField = (options.avatarField || '').trim();
this.mergeUsers = options.mergeUsers;
this.mergeUsersDistinctServices = options.mergeUsersDistinctServices;
this.rolesClaim = options.rolesClaim || 'roles';
this.accessTokenParam = options.accessTokenParam;
this.channelsAdmin = options.channelsAdmin || 'rocket.cat';
if (this.identityTokenSentVia == null || this.identityTokenSentVia === 'default') {
this.identityTokenSentVia = this.tokenSentVia;
}
if (!isAbsoluteURL(this.tokenPath)) {
this.tokenPath = this.serverURL + this.tokenPath;
}
if (!isAbsoluteURL(this.identityPath)) {
this.identityPath = this.serverURL + this.identityPath;
}
if (this.emailPath && !isAbsoluteURL(this.emailPath)) {
this.emailPath = this.serverURL + this.emailPath;
}
if (Match.test(options.addAutopublishFields, Object)) {
Accounts.addAutopublishFields(options.addAutopublishFields);
}
}
async getAccessToken(query) {
const config = await ServiceConfiguration.configurations.findOneAsync({ service: this.name });
if (!config) {
throw new Accounts.ConfigError();
}
let response = undefined;
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': this.userAgent, // http://doc.gitlab.com/ce/api/users.html#Current-user
'Accept': 'application/json',
};
const params = new URLSearchParams({
code: query.code,
redirect_uri: OAuth._redirectUri(this.name, config),
grant_type: 'authorization_code',
state: query.state,
});
// Only send clientID / secret once on header or payload.
if (this.tokenSentVia === 'header') {
const b64 = Buffer.from(`${config.clientId}:${OAuth.openSecret(config.secret)}`).toString('base64');
headers.Authorization = `Basic ${b64}`;
} else {
params.append('client_secret', config.secret);
params.append('client_id', config.clientId);
}
try {
const request = await fetch(`${this.tokenPath}`, {
// SECURITY: URL can only be configured by users with enough privileges. It's ok to disable this check here.
ignoreSsrfValidation: true,
method: 'POST',
headers,
body: params,
});
if (!request.ok) {
throw new Error(request.statusText);
}
response = await request.json();
} catch (err) {
const error = new Error(`Failed to complete OAuth handshake with ${this.name} at ${this.tokenPath}. ${err.message}`);
throw _.extend(error, { response: err.response });
}
if (response.error) {
// if the http response was a json object with an error attribute
throw new Error(`Failed to complete OAuth handshake with ${this.name} at ${this.tokenPath}. ${response.error}`);
} else {
return response;
}
}
async getIdentity(accessToken) {
const params = {};
const headers = {
'User-Agent': this.userAgent, // http://doc.gitlab.com/ce/api/users.html#Current-user
'Accept': 'application/json',
};
if (this.identityTokenSentVia === 'header') {
headers.Authorization = `Bearer ${accessToken}`;
} else {
params[this.accessTokenParam] = accessToken;
}
try {
// SECURITY: URL can only be configured by users with enough privileges. It's ok to disable this check here.
const request = await fetch(`${this.identityPath}`, { method: 'GET', headers, params, ignoreSsrfValidation: true });
if (!request.ok) {
throw new Error(request.statusText);
}
const response = await request.json();
logger.debug({ msg: 'Identity response', response });
return this.normalizeIdentity(response, accessToken);
} catch (err) {
const error = new Error(`Failed to fetch identity from ${this.name} at ${this.identityPath}. ${err.message}`);
throw _.extend(error, { response: err.response });
}
}
registerService() {
const self = this;
OAuth.registerService(this.name, 2, null, async (query) => {
const response = await self.getAccessToken(query);
const identity = await self.getIdentity(response.access_token, query);
const serviceData = {
_OAuthCustom: true,
serverURL: self.serverURL,
accessToken: response.access_token,
idToken: response.id_token,
expiresAt: +new Date() + 1000 * parseInt(response.expires_in, 10),
};
// only set the token in serviceData if it's there. this ensures
// that we don't lose old ones (since we only get this on the first
// log in attempt)
if (response.refresh_token) {
serviceData.refreshToken = response.refresh_token;
}
_.extend(serviceData, identity);
const data = {
serviceData,
options: {
profile: {
name: identity.name,
},
},
};
return data;
});
}
async normalizeIdentity(identity, accessToken) {
if (identity) {
for (const normalizer of Object.values(normalizers)) {
const result = normalizer(identity);
if (result) {
identity = result;
}
}
}
if (this.usernameField) {
identity.username = this.getUsername(identity);
}
if (this.emailField) {
identity.email = this.getEmail(identity);
}
if (!identity.email && this.emailPath) {
identity.email = await this.getEmailFromPath(accessToken);
}
if (this.avatarField) {
identity.avatarUrl = this.getAvatarUrl(identity);
}
if (this.nameField) {
identity.name = this.getCustomName(identity);
} else {
identity.name = this.getName(identity);
}
return renameInvalidProperties(identity);
}
async getEmailFromPath(accessToken) {
if (!this.emailPath) {
throw new Meteor.Error('CustomOAuth: emailPath is required');
}
const params = {};
const headers = {
'User-Agent': this.userAgent,
'Accept': 'application/json',
};
if (this.identityTokenSentVia === 'header') {
headers.Authorization = `Bearer ${accessToken}`;
} else {
params[this.accessTokenParam] = accessToken;
}
try {
// SECURITY: URL can only be configured by users with enough privileges. It's ok to disable this check here.
const request = await fetch(`${this.emailPath}`, { method: 'GET', headers, params, ignoreSsrfValidation: true });
if (!request.ok) {
throw new Error(request.statusText);
}
const response = await request.json();
return response.find((email) => email.primary === true)?.email;
} catch (err) {
const error = new Error(`Failed to fetch emails from ${this.name} at ${this.emailPath}. ${err.message}`);
throw _.extend(error, { response: err.response });
}
}
retrieveCredential(credentialToken, credentialSecret) {
return OAuth.retrieveCredential(credentialToken, credentialSecret);
}
getUsername(data) {
try {
const value = fromTemplate(this.usernameField, data);
if (!value) {
throw new Meteor.Error('field_not_found', `Username field "${this.usernameField}" not found in data`, data);
}
return value;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract username', error.message);
}
}
getEmail(data) {
try {
const value = fromTemplate(this.emailField, data);
if (!value) {
throw new Meteor.Error('field_not_found', `Email field "${this.emailField}" not found in data`, data);
}
return value;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract email', error.message);
}
}
getCustomName(data) {
try {
const value = fromTemplate(this.nameField, data);
if (!value) {
return this.getName(data);
}
return value;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract custom name', error.message);
}
}
getAvatarUrl(data) {
try {
const value = fromTemplate(this.avatarField, data);
if (!value) {
logger.debug({ msg: 'Avatar field not found in data', avatarField: this.avatarField, data });
}
return value;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract avatar url', error.message);
}
}
getName(identity) {
const name =
identity.name ||
identity.username ||
identity.nickname ||
identity.CharacterName ||
identity.userName ||
identity.preferred_username ||
(identity.user && identity.user.name);
return name;
}
addHookToProcessUser() {
BeforeUpdateOrCreateUserFromExternalService.push(async (serviceName, serviceData /* , options*/) => {
if (serviceName !== this.name) {
return;
}
if (serviceData.username) {
let user = undefined;
if (this.keyField === 'username') {
user = this.mergeUsersDistinctServices
? await Users.findOneByUsernameIgnoringCase(serviceData.username)
: await Users.findOneByUsernameAndServiceNameIgnoringCase(serviceData.username, serviceData.id, serviceName);
} else if (this.keyField === 'email') {
user = this.mergeUsersDistinctServices
? await Users.findOneByEmailAddress(serviceData.email)
: await Users.findOneByEmailAddressAndServiceNameIgnoringCase(serviceData.email, serviceData.id, serviceName);
}
if (!user) {
return;
}
await callbacks.run('afterProcessOAuthUser', { serviceName, serviceData, user });
// User already created or merged and has identical name as before
if (
user.services &&
user.services[serviceName] &&
user.services[serviceName].id === serviceData.id &&
user.name === serviceData.name &&
(this.keyField === 'email' || !serviceData.email || user.emails?.find(({ address }) => address === serviceData.email))
) {
return;
}
if (this.mergeUsers !== true) {
throw new Meteor.Error('CustomOAuth', `User with username ${user.username} already exists`);
}
const serviceIdKey = `services.${serviceName}.id`;
const successCallbacks = [
async () => {
const updatedUser = await Users.findOneById(user._id, { projection: { name: 1, emails: 1, [serviceIdKey]: 1 } });
if (updatedUser) {
const { _id, ...diff } = updatedUser;
void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff });
}
},
];
const session = client.startSession();
try {
// Extend the session to match the ExtendedSession type expected by saveUserIdentity
Object.assign(session, {
onceSuccesfulCommit: (cb) => {
successCallbacks.push(cb);
},
});
session.startTransaction();
const updater = Users.getUpdater();
if (this.keyField === 'username' && serviceData.email) {
updater.set('emails', [{ address: serviceData.email, verified: true }]);
}
updater.set(serviceIdKey, serviceData.id);
await saveUserIdentity({
_id: user._id,
name: serviceData.name,
updater,
session,
updateUsernameInBackground: true,
// Username needs to be included otherwise the name won't be updated in some collections
username: user.username,
});
await Users.updateFromUpdater({ _id: user._id }, updater, { session });
await session.commitTransaction();
} catch (e) {
await session.abortTransaction();
throw e;
} finally {
await session.endSession();
}
void Promise.allSettled(successCallbacks.map((cb) => cb()));
}
});
Accounts.validateNewUser((user) => {
if (!user.services || !user.services[this.name] || !user.services[this.name].id) {
return true;
}
if (this.usernameField) {
user.username = user.services[this.name].username;
}
if (this.emailField) {
user.email = user.services[this.name].email;
}
if (this.nameField) {
user.name = user.services[this.name].name;
}
return true;
});
}
registerAccessTokenService(name) {
const self = this;
const whitelisted = ['id', 'email', 'username', 'name', this.rolesClaim];
registerAccessTokenService(name, async (options) => {
check(
options,
Match.ObjectIncluding({
accessToken: String,
expiresIn: Match.Integer,
}),
);
const identity = await self.getIdentity(options.accessToken);
const serviceData = {
accessToken: options.accessToken,
expiresAt: +new Date() + 1000 * parseInt(options.expiresIn, 10),
};
const fields = _.pick(identity, whitelisted);
_.extend(serviceData, fields);
return {
serviceData,
options: {
profile: {
name: identity.name,
},
},
};
});
}
}
const { updateOrCreateUserFromExternalService } = Accounts;
Accounts.updateOrCreateUserFromExternalService = async function (...args /* serviceName, serviceData, options*/) {
for (const hook of BeforeUpdateOrCreateUserFromExternalService) {
await hook.apply(this, args);
}
const [serviceName, serviceData] = args;
const user = await updateOrCreateUserFromExternalService.apply(this, args);
if (!user.userId) {
return undefined;
}
const fullUser = await Users.findOneById(user.userId);
if (settings.get('LDAP_Update_Data_On_OAuth_Login')) {
await LDAP.loginAuthenticatedUserRequest(fullUser.username);
}
await callbacks.run('afterValidateNewOAuthUser', {
identity: serviceData,
serviceName,
user: fullUser,
});
return user;
};

View File

@@ -0,0 +1,160 @@
import { isObject } from '../../../../lib/utils/isObject';
export const normalizers = {
// Set 'id' to '_id' for any sources that provide it
_id(identity) {
if (identity._id && !identity.id) {
identity.id = identity._id;
}
},
// Fix for Reddit
redit(identity) {
if (identity.result) {
return identity.result;
}
},
// Fix WordPress-like identities having 'ID' instead of 'id'
wordpress(identity) {
if (identity.ID && !identity.id) {
identity.id = identity.ID;
}
},
// Fix Auth0-like identities having 'user_id' instead of 'id'
user_id(identity) {
if (identity.user_id && !identity.id) {
identity.id = identity.user_id;
}
},
characterid(identity) {
if (identity.CharacterID && !identity.id) {
identity.id = identity.CharacterID;
}
},
// Fix Dataporten having 'user.userid' instead of 'id'
dataporten(identity) {
if (identity.user && identity.user.userid && !identity.id) {
if (identity.user.userid_sec && identity.user.userid_sec[0]) {
identity.id = identity.user.userid_sec[0];
} else {
identity.id = identity.user.userid;
}
identity.email = identity.user.email;
}
},
// Fix for Xenforo [BD]API plugin for 'user.user_id; instead of 'id'
xenforo(identity) {
if (identity.user && identity.user.user_id && !identity.id) {
identity.id = identity.user.user_id;
identity.email = identity.user.user_email;
}
},
// Fix general 'phid' instead of 'id' from phabricator
phabricator(identity) {
if (identity.phid && !identity.id) {
identity.id = identity.phid;
}
},
// Fix Keycloak-like identities having 'sub' instead of 'id'
kaycloak(identity) {
if (identity.sub && !identity.id) {
identity.id = identity.sub;
}
},
// Fix OpenShift identities where id is in 'metadata' object
openshift(identity) {
if (!identity.id && identity.metadata && identity.metadata.uid) {
identity.id = identity.metadata.uid;
identity.name = identity.fullName;
}
},
// Fix general 'userid' instead of 'id' from provider
userid(identity) {
if (identity.userid && !identity.id) {
identity.id = identity.userid;
}
},
// Fix Nextcloud provider
nextcloud(identity) {
if (!identity.id && identity.ocs && identity.ocs.data && identity.ocs.data.id) {
identity.id = identity.ocs.data.id;
identity.name = identity.ocs.data.displayname;
identity.email = identity.ocs.data.email;
}
},
// Fix when authenticating from a meteor app with 'emails' field
meteor(identity) {
if (!identity.email && identity.emails && Array.isArray(identity.emails) && identity.emails.length >= 1) {
identity.email = identity.emails[0].address ? identity.emails[0].address : undefined;
}
},
};
const IDENTITY_PROPNAME_FILTER = /(\.)/g;
export const renameInvalidProperties = (input) => {
if (Array.isArray(input)) {
return input.map(renameInvalidProperties);
}
if (!isObject(input)) {
return input;
}
return Object.entries(input).reduce(
(result, [name, value]) => ({
...result,
[name.replace(IDENTITY_PROPNAME_FILTER, '_')]: renameInvalidProperties(value),
}),
{},
);
};
export const getNestedValue = (propertyPath, source) =>
propertyPath.split('.').reduce((prev, curr) => (prev ? prev[curr] : undefined), source);
// /^(.+)@/::email
const REGEXP_FROM_FORMULA = /^\/((?!\/::).*)\/::(.+)/;
export const getRegexpMatch = (formula, data) => {
const regexAndPath = REGEXP_FROM_FORMULA.exec(formula);
if (!regexAndPath) {
return getNestedValue(formula, data);
}
if (regexAndPath.length !== 3) {
throw new Error(`expected array of length 3, got ${regexAndPath.length}`);
}
const [, regexString, path] = regexAndPath;
const nestedValue = getNestedValue(path, data);
const regex = new RegExp(regexString);
const matches = regex.exec(nestedValue);
// regexp does not match nested value
if (!matches) {
return undefined;
}
// we only support regular expressions with a single capture group
const [, value] = matches;
// this could mean we return `undefined` (e.g. when capture group is empty)
return value;
};
const templateStringRegex = /{{((?:(?!}}).)+)}}/g;
export const fromTemplate = (template, data) => {
if (!templateStringRegex.test(template)) {
return getNestedValue(template, data);
}
return template.replace(templateStringRegex, (fullMatch, match) => getRegexpMatch(match, data));
};

View File

@@ -0,0 +1,91 @@
import type { IUser, OAuthConfiguration } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import { ServiceConfiguration } from 'meteor/service-configuration';
import passport from 'passport';
import _ from 'underscore';
import { callbacks } from '../callbacks';
import { beforeCreateUserCallback } from '../callbacks/beforeCreateUserCallback';
import { addPassportCustomOAuth } from '../oauth/addPassportCustomOAuth';
import { CustomOAuth } from './custom-oauth/custom_oauth_server';
import { settings } from '../../settings';
const config: Partial<OAuthConfiguration> = {
serverURL: '',
authorizePath: '/m/oauth2/auth/',
tokenPath: '/m/oauth2/token/',
identityPath: '/m/oauth2/api/me/',
scope: 'basic',
addAutopublishFields: {
forLoggedInUser: ['services.dolphin'],
forOtherUsers: ['services.dolphin.name'],
},
accessTokenParam: 'access_token',
};
const Dolphin = new CustomOAuth('dolphin', config);
function DolphinOnCreateUser(options: any, user?: IUser) {
// TODO: callbacks Fix this
if (user?.services?.dolphin?.NickName) {
user.username = user.services.dolphin.NickName;
}
return options;
}
const configureDolphinOAuth = () => {
passport.unuse('dolphin');
const enabled = settings.get<boolean>('Accounts_OAuth_Dolphin');
if (!enabled) {
return;
}
const serverURL = settings.get<string>('Accounts_OAuth_Dolphin_URL').trim().replace(/\/*$/, '');
const clientId = settings.get<string>('Accounts_OAuth_Dolphin_id');
const clientSecret = settings.get<string>('Accounts_OAuth_Dolphin_secret');
if (!clientId || !clientSecret || !serverURL) {
return;
}
const completeConfig = { ...config, serverURL, clientId, clientSecret };
if (settings.get<boolean>('Accounts_OAuth_Use_Modern_Flow')) {
addPassportCustomOAuth('dolphin', completeConfig);
return;
}
Dolphin.configure(completeConfig);
};
Meteor.startup(async () => {
const updateConfig = _.debounce(configureDolphinOAuth, 300);
settings.watchMultiple(
[
'Accounts_OAuth_Dolphin',
'Accounts_OAuth_Dolphin_URL',
'Accounts_OAuth_Dolphin_id',
'Accounts_OAuth_Dolphin_secret',
'Accounts_OAuth_Use_Modern_Flow',
],
updateConfig,
);
if (settings.get('Accounts_OAuth_Dolphin_URL')) {
const data = {
buttonLabelText: settings.get<string>('Accounts_OAuth_Dolphin_button_label_text'),
buttonColor: settings.get<string>('Accounts_OAuth_Dolphin_button_color'),
buttonLabelColor: settings.get<string>('Accounts_OAuth_Dolphin_button_label_color'),
clientId: settings.get<string>('Accounts_OAuth_Dolphin_id'),
secret: settings.get<string>('Accounts_OAuth_Dolphin_secret'),
serverURL: settings.get<string>('Accounts_OAuth_Dolphin_URL'),
loginStyle: settings.get<string>('Accounts_OAuth_Dolphin_login_style'),
};
await ServiceConfiguration.configurations.upsertAsync({ service: 'dolphin' }, { $set: data });
}
beforeCreateUserCallback.add(DolphinOnCreateUser, callbacks.priority.HIGH, 'dolphin');
});

View File

@@ -0,0 +1,23 @@
#Drupal oAuth Integration module.
This module works in conjunction with the [Rocket.Chat+ Module for Drupal](https://www.drupal.org/project/rocket_chat)
Version 7.x-1.1 or later.
A full set of instructions for how to connect the 2 are present in the drupal module's documentation.
Basically to connect the 2 you first setup the oAuth server connection in your drupal, with the proper permissions
("Use OAuth2 Server" => "Anonymous User" = Checked).
In the Rocket chat you have to do the following:
- fill in the 'Client ID'.
Bear in mind that the Client ID should not be guessable,but is seen in the URL when doing the login.
- fill in the 'Client Secret'.
This should be treated as a Secret Key (like the Secret Key of a TLS certificate). it __must not__ be guesable or
derivable, and is best a Alphanumerical sequence between 16 and 48 cahracters long (longer would be better but longer
than 48 characters can be problem with long URI's)
- fill in the Drupal's BaseURL.
- on the Drupal use the "Restrict redirect URIs" Setting to limit possible exploits. and set the Redirect URI's to
whatever is in the Callback URL (like `https://Rocketchat.example.com/_oauth/drupal` and possibly also the
`https://Rocketchat.example.com/_oauth/drupal?close` URI.).
- Lastly do not forget to Enable the Drupal OAuth and `SAVE CHANGES`.
When all is a Blue Button with a drupal like logo will apear on the login page of Rocket.Chat+

View File

@@ -0,0 +1,66 @@
import type { OAuthConfiguration } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import passport from 'passport';
import _ from 'underscore';
import { addPassportCustomOAuth } from '../oauth/addPassportCustomOAuth';
import { CustomOAuth } from './custom-oauth/custom_oauth_server';
import { settings } from '../../settings';
const config: Partial<OAuthConfiguration> = {
serverURL: '',
identityPath: '/oauth2/UserInfo',
authorizePath: '/oauth2/authorize',
tokenPath: '/oauth2/token',
scope: 'openid email profile offline_access',
tokenSentVia: 'payload',
usernameField: 'preferred_username',
mergeUsers: true,
addAutopublishFields: {
forLoggedInUser: ['services.drupal'],
forOtherUsers: ['services.drupal.name'],
},
accessTokenParam: 'access_token',
};
const Drupal = new CustomOAuth('drupal', config);
const configureDrupalOAuth = () => {
passport.unuse('drupal');
const enabled = settings.get<boolean>('Accounts_OAuth_Drupal');
if (!enabled) {
return;
}
const serverURL = settings.get<string>('API_Drupal_URL').trim().replace(/\/*$/, '');
const clientId = settings.get<string>('Accounts_OAuth_Drupal_id');
const clientSecret = settings.get<string>('Accounts_OAuth_Drupal_secret');
if (!clientId || !clientSecret || !serverURL) {
return;
}
const completeConfig = { ...config, serverURL, clientId, clientSecret };
if (settings.get<boolean>('Accounts_OAuth_Use_Modern_Flow')) {
addPassportCustomOAuth('drupal', completeConfig);
return;
}
Drupal.configure(completeConfig);
};
Meteor.startup(() => {
const updateConfig = _.debounce(configureDrupalOAuth, 300);
settings.watchMultiple(
[
'Accounts_OAuth_Drupal',
'API_Drupal_URL',
'Accounts_OAuth_Drupal_id',
'Accounts_OAuth_Drupal_secret',
'Accounts_OAuth_Use_Modern_Flow',
],
updateConfig,
);
});

View File

@@ -0,0 +1,28 @@
import type { OauthConfig } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import { CustomOAuth } from './custom-oauth/custom_oauth_server';
import { settings } from '../../settings';
// GitHub Enterprise Server CallBack URL needs to be http(s)://{rocketchat.server}[:port]/_oauth/github_enterprise
// In RocketChat -> Administration the URL needs to be http(s)://{github.enterprise.server}/
const config: OauthConfig = {
serverURL: '',
identityPath: '/api/v3/user',
authorizePath: '/login/oauth/authorize',
tokenPath: '/login/oauth/access_token',
addAutopublishFields: {
forLoggedInUser: ['services.github-enterprise'],
forOtherUsers: ['services.github-enterprise.username'],
},
};
const GitHubEnterprise = new CustomOAuth('github_enterprise', config);
Meteor.startup(() => {
settings.watch<string>('API_GitHub_Enterprise_URL', (value) => {
config.serverURL = value;
GitHubEnterprise.configure(config);
});
});

View File

@@ -0,0 +1,20 @@
import type { OauthConfig } from '@rocket.chat/core-typings';
import { CustomOAuth } from './custom-oauth/custom_oauth_server';
const config: OauthConfig = {
serverURL: 'https://github.com',
identityPath: 'https://api.github.com/user',
tokenPath: 'https://github.com/login/oauth/access_token',
emailPath: 'https://api.github.com/user/emails',
scope: 'user:email',
mergeUsers: false,
addAutopublishFields: {
forLoggedInUser: ['services.github'],
forOtherUsers: ['services.github.username'],
},
accessTokenParam: 'access_token',
identityTokenSentVia: 'header',
};
export const Github = new CustomOAuth('github', config);

View File

@@ -0,0 +1,69 @@
import type { OAuthConfiguration } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import passport from 'passport';
import _ from 'underscore';
import { addPassportCustomOAuth } from '../oauth/addPassportCustomOAuth';
import { CustomOAuth } from './custom-oauth/custom_oauth_server';
import { settings } from '../../settings';
const config: Partial<OAuthConfiguration> = {
serverURL: 'https://gitlab.com',
identityPath: '/api/v4/user',
authorizePath: '/oauth/authorize',
tokenPath: '/oauth/token',
scope: 'read_user',
mergeUsers: false,
addAutopublishFields: {
forLoggedInUser: ['services.gitlab'],
forOtherUsers: ['services.gitlab.username'],
},
accessTokenParam: 'access_token',
};
const Gitlab = new CustomOAuth('gitlab', config);
const configureGitlabOAuth = () => {
passport.unuse('gitlab');
const enabled = settings.get<boolean>('Accounts_OAuth_Gitlab');
if (!enabled) {
return;
}
const clientId = settings.get<string>('Accounts_OAuth_Gitlab_id');
const clientSecret = settings.get<string>('Accounts_OAuth_Gitlab_secret');
const serverURL = settings.get<string>('API_Gitlab_URL').trim().replace(/\/*$/, '') || config.serverURL;
const identityPath = settings.get<string>('Accounts_OAuth_Gitlab_identity_path') || config.identityPath;
const mergeUsers = Boolean(settings.get<boolean>('Accounts_OAuth_Gitlab_merge_users'));
if (!clientId || !clientSecret) {
return;
}
const completeConfig = { ...config, clientId, clientSecret, serverURL, identityPath, mergeUsers };
if (settings.get<boolean>('Accounts_OAuth_Use_Modern_Flow')) {
addPassportCustomOAuth('gitlab', completeConfig);
return;
}
Gitlab.configure(completeConfig);
};
Meteor.startup(() => {
const updateConfig = _.debounce(configureGitlabOAuth, 300);
settings.watchMultiple(
[
'Accounts_OAuth_Gitlab',
'API_Gitlab_URL',
'Accounts_OAuth_Gitlab_id',
'Accounts_OAuth_Gitlab_secret',
'Accounts_OAuth_Gitlab_identity_path',
'Accounts_OAuth_Gitlab_merge_users',
'Accounts_OAuth_Use_Modern_Flow',
],
updateConfig,
);
});

View File

@@ -0,0 +1,106 @@
import { Meteor } from 'meteor/meteor';
import { OAuth } from 'meteor/oauth';
// The code on this file was copied directly from Meteor and modified to support mobile google oauth
// https://github.com/meteor/meteor/blob/ffcfa5062cf1bf8a64ea64fef681ffcd99fe7939/packages/oauth/oauth_server.js
Meteor.startup(() => {
const appRedirectUrl = 'rocketchat://auth';
const renderEndOfLoginResponse = async (options) => {
const escape = (s) => {
if (!s) {
return s;
}
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\'/g, '&quot;')
.replace(/\'/g, '&#x27;')
.replace(/\//g, '&#x2F;');
};
const config = {
setCredentialToken: !!options.setCredentialToken,
credentialToken: escape(options.credentialToken),
credentialSecret: escape(options.credentialSecret),
storagePrefix: escape(OAuth._storageTokenPrefix),
redirectUrl: escape(options.redirectUrl),
isCordova: Boolean(options.isCordova),
};
let template;
if (options.loginStyle === 'popup') {
template = await OAuth._endOfPopupResponseTemplate();
} else if (options.loginStyle === 'redirect') {
template = await OAuth._endOfRedirectResponseTemplate();
} else {
throw new Error(`invalid loginStyle: ${options.loginStyle}`);
}
const result = template
.replace(/##CONFIG##/, JSON.stringify(config))
.replace(/##ROOT_URL_PATH_PREFIX##/, __meteor_runtime_config__.ROOT_URL_PATH_PREFIX);
return `<!DOCTYPE html>\n${result}`;
};
OAuth._endOfLoginResponse = async (res, details) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
let redirectUrl;
if (details.loginStyle === 'redirect') {
redirectUrl = OAuth._stateFromQuery(details.query).redirectUrl;
const appHost = Meteor.absoluteUrl();
if (redirectUrl.startsWith(appRedirectUrl)) {
redirectUrl = `${appRedirectUrl}?host=${appHost}&type=oauth`;
if (details.error) {
const error = encodeURIComponent(details.error.toString());
redirectUrl = `${redirectUrl}&error=${error}`;
}
if (details.credentials) {
const { token, secret } = details.credentials;
redirectUrl = `${redirectUrl}&credentialToken=${token}&credentialSecret=${secret}`;
}
} else if (!Meteor.settings?.packages?.oauth?.disableCheckRedirectUrlOrigin && OAuth._checkRedirectUrlOrigin(redirectUrl)) {
details.error = `redirectUrl (${redirectUrl}) is not on the same host as the app (${appHost})`;
redirectUrl = appHost;
}
}
const isCordova = OAuth._isCordovaFromQuery(details.query);
if (details.error) {
res.end(
await renderEndOfLoginResponse({
loginStyle: details.loginStyle,
setCredentialToken: false,
redirectUrl,
isCordova,
}),
'utf-8',
);
return;
}
// If we have a credentialSecret, report it back to the parent
// window, with the corresponding credentialToken. The parent window
// uses the credentialToken and credentialSecret to log in over DDP.
res.end(
await renderEndOfLoginResponse({
loginStyle: details.loginStyle,
setCredentialToken: true,
credentialToken: details.credentials.token,
credentialSecret: details.credentials.secret,
redirectUrl,
isCordova,
}),
'utf-8',
);
};
});

View File

@@ -0,0 +1,37 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Users } from '@rocket.chat/models';
import { Accounts } from 'meteor/accounts-base';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { OAuth } from 'meteor/oauth';
Accounts.registerLoginHandler('iframe', async (result) => {
if (!result.iframe) {
return;
}
check(result.token, String);
const user = await Users.findOne({
'services.iframe.token': result.token,
});
if (user) {
return {
userId: user._id,
};
}
});
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'OAuth.retrieveCredential'(credentialToken: string, credentialSecret: string): unknown;
}
}
Meteor.methods<ServerMethods>({
'OAuth.retrieveCredential'(credentialToken, credentialSecret) {
return OAuth.retrieveCredential(credentialToken, credentialSecret);
},
});

View File

@@ -0,0 +1,49 @@
import type { OAuthConfiguration } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import passport from 'passport';
import { settings } from '../../settings';
import { addPassportCustomOAuth } from '../oauth/addPassportCustomOAuth';
const config: Partial<OAuthConfiguration> = {
serverURL: 'https://www.linkedin.com',
authorizePath: '/oauth/v2/authorization',
tokenPath: '/oauth/v2/accessToken',
identityPath: 'https://api.linkedin.com/v2/userinfo',
scope: 'openid email profile',
tokenSentVia: 'header',
addAutopublishFields: {
forLoggedInUser: ['services.linkedin'],
forOtherUsers: ['services.linkedin.name'],
},
pkce: false,
emailField: 'email',
avatarField: 'picture',
};
const serviceKey = 'linkedin';
const configureLinkedInOAuth = (): void => {
passport.unuse(serviceKey);
const enabled = settings.get<boolean>('Accounts_OAuth_Linkedin');
if (!enabled) {
return;
}
const clientId = settings.get<string>('Accounts_OAuth_Linkedin_id');
const clientSecret = settings.get<string>('Accounts_OAuth_Linkedin_secret');
if (!clientId || !clientSecret) {
return;
}
addPassportCustomOAuth(serviceKey, { ...config, clientId, clientSecret });
};
Meteor.startup(() => {
settings.watchMultiple(
['Accounts_OAuth_Linkedin', 'Accounts_OAuth_Linkedin_id', 'Accounts_OAuth_Linkedin_secret'],
configureLinkedInOAuth,
);
});

View File

@@ -0,0 +1,46 @@
import type { OAuthConfiguration } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import passport from 'passport';
import { settings } from '../../settings';
import { addPassportCustomOAuth } from '../oauth/addPassportCustomOAuth';
const config: Partial<OAuthConfiguration> = {
serverURL: 'https://www.meteor.com',
authorizePath: '/oauth2/authorize',
tokenPath: '/oauth2/token',
identityPath: '/api/v1/identity',
scope: 'email',
tokenSentVia: 'header',
addAutopublishFields: {
forLoggedInUser: ['services.meteor-developer'],
forOtherUsers: ['services.meteor-developer.username'],
},
};
const serviceKey = 'meteor';
const configureMeteorDeveloperOAuth = (): void => {
passport.unuse(serviceKey);
const enabled = settings.get<boolean>('Accounts_OAuth_Meteor');
if (!enabled) {
return;
}
const clientId = settings.get<string>('Accounts_OAuth_Meteor_id');
const clientSecret = settings.get<string>('Accounts_OAuth_Meteor_secret');
if (!clientId || !clientSecret) {
return;
}
addPassportCustomOAuth(serviceKey, { ...config, clientId, clientSecret });
};
Meteor.startup(() => {
settings.watchMultiple(
['Accounts_OAuth_Meteor', 'Accounts_OAuth_Meteor_id', 'Accounts_OAuth_Meteor_secret'],
configureMeteorDeveloperOAuth,
);
});

View File

@@ -0,0 +1,68 @@
import crypto from 'node:crypto';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { Match, check } from 'meteor/check';
import { OAuth } from 'meteor/oauth';
import _ from 'underscore';
import { registerAccessTokenService } from './oauth';
const whitelisted = ['id', 'email', 'name', 'first_name', 'last_name', 'link', 'gender', 'locale', 'age_range'];
const FB_API_VERSION = 'v2.9';
const FB_URL = 'https://graph.facebook.com';
const getIdentity = async function (accessToken, fields, secret) {
const hmac = crypto.createHmac('sha256', OAuth.openSecret(secret));
hmac.update(accessToken);
try {
const request = await fetch(`${FB_URL}/${FB_API_VERSION}/me`, {
params: {
access_token: accessToken,
appsecret_proof: hmac.digest('hex'),
fields: fields.join(','),
},
});
if (!request.ok) {
throw new Error(await request.text());
}
return request.json();
} catch (err) {
throw _.extend(new Error(`Failed to fetch identity from Facebook. ${err.message}`), {
response: err.message,
});
}
};
registerAccessTokenService('facebook', async (options) => {
check(
options,
Match.ObjectIncluding({
accessToken: String,
secret: String,
expiresIn: Match.Integer,
}),
);
const identity = await getIdentity(options.accessToken, whitelisted, options.secret);
const serviceData = {
accessToken: options.accessToken,
expiresAt: +new Date() + 1000 * parseInt(options.expiresIn, 10),
};
const fields = _.pick(identity, whitelisted);
_.extend(serviceData, fields);
return {
serviceData,
options: {
profile: {
name: identity.name,
},
},
};
});

View File

@@ -0,0 +1,83 @@
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { Match, check } from 'meteor/check';
import { Google } from 'meteor/google-oauth';
import _ from 'underscore';
import { registerAccessTokenService } from './oauth';
async function getIdentity(accessToken) {
try {
const request = await fetch('https://www.googleapis.com/oauth2/v1/userinfo', {
params: { access_token: accessToken },
});
if (!request.ok) {
throw new Error(await request.text());
}
return request.json();
} catch (err) {
throw _.extend(new Error(`Failed to fetch identity from Google. ${err.message}`), {
response: err.message,
});
}
}
async function getScopes(accessToken) {
try {
const request = await fetch('https://www.googleapis.com/oauth2/v1/tokeninfo', {
params: { access_token: accessToken },
});
if (!request.ok) {
throw new Error(await request.text());
}
return (await request.json()).scope.split(' ');
} catch (err) {
throw _.extend(new Error(`Failed to fetch tokeninfo from Google. ${err.message}`), {
response: err.message,
});
}
}
registerAccessTokenService('google', async (options) => {
check(
options,
Match.ObjectIncluding({
accessToken: String,
idToken: String,
expiresIn: Match.Integer,
scope: Match.Maybe(String),
identity: Match.Maybe(Object),
}),
);
const identity = await getIdentity(options.accessToken);
const serviceData = {
accessToken: options.accessToken,
idToken: options.idToken,
expiresAt: +new Date() + 1000 * parseInt(options.expiresIn, 10),
scope: options.scopes || (await getScopes(options.accessToken)),
};
const fields = _.pick(identity, Google.whitelistedFields);
_.extend(serviceData, fields);
// only set the token in serviceData if it's there. this ensures
// that we don't lose old ones (since we only get this on the first
// log in attempt)
if (options.refreshToken) {
serviceData.refreshToken = options.refreshToken;
}
return {
serviceData,
options: {
profile: {
name: identity.name,
},
},
};
});

View File

@@ -0,0 +1,55 @@
import { Accounts } from 'meteor/accounts-base';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { ServiceConfiguration } from 'meteor/service-configuration';
import _ from 'underscore';
const AccessTokenServices = {};
export const registerAccessTokenService = function (serviceName, handleAccessTokenRequest) {
AccessTokenServices[serviceName] = {
serviceName,
handleAccessTokenRequest,
};
};
// Listen to calls to `login` with an oauth option set. This is where
// users actually get logged in to meteor via oauth.
Accounts.registerLoginHandler(async (options) => {
if (!options.accessToken) {
return undefined; // don't handle
}
check(
options,
Match.ObjectIncluding({
serviceName: String,
}),
);
const service = AccessTokenServices[options.serviceName];
// Skip everything if there's no service set by the oauth middleware
if (!service) {
throw new Error(`Unexpected AccessToken service ${options.serviceName}`);
}
// Make sure we're configured
if (!(await ServiceConfiguration.configurations.findOneAsync({ service: options.serviceName }))) {
throw new Accounts.ConfigError();
}
if (!_.contains(Accounts.oauth.serviceNames(), service.serviceName)) {
// serviceName was not found in the registered services list.
// This could happen because the service never registered itself or
// unregisterService was called on it.
return {
type: 'oauth',
error: new Meteor.Error(Accounts.LoginCancelledError.numericError, `No registered oauth service found for: ${service.serviceName}`),
};
}
const oauthResult = await service.handleAccessTokenRequest(options);
return Accounts.updateOrCreateUserFromExternalService(service.serviceName, oauthResult.serviceData, oauthResult.options);
});

View File

@@ -0,0 +1,12 @@
import { OAuth } from 'meteor/oauth';
import _ from 'underscore';
import { settings } from '../../../settings';
OAuth._redirectUri = _.wrap(OAuth._redirectUri, (func, serviceName, ...args) => {
const proxy = settings.get('Accounts_OAuth_Proxy_services').replace(/\s/g, '').split(',');
if (proxy.includes(serviceName)) {
return `${settings.get('Accounts_OAuth_Proxy_host')}/oauth_redirect`;
}
return func(serviceName, ...args);
});

View File

@@ -0,0 +1,55 @@
import { Match, check } from 'meteor/check';
import { TwitterApi } from 'twitter-api-v2';
import _ from 'underscore';
import { registerAccessTokenService } from './oauth';
const whitelistedFields = ['id', 'name', 'description', 'profile_image_url', 'profile_image_url_https', 'lang', 'email'];
const getIdentity = async function (accessToken, appId, appSecret, accessTokenSecret) {
const client = new TwitterApi({
appKey: appId,
appSecret,
accessToken,
accessSecret: accessTokenSecret,
});
try {
return await client.v1.verifyCredentials({ include_email: true });
} catch (err) {
throw _.extend(new Error(`Failed to fetch identity from Twitter. ${err.message}`), {
data: err.data ?? err.response,
});
}
};
registerAccessTokenService('twitter', async (options) => {
check(
options,
Match.ObjectIncluding({
accessToken: String,
appSecret: String,
appId: String,
accessTokenSecret: String,
expiresIn: Match.Integer,
}),
);
const identity = await getIdentity(options.accessToken, options.appId, options.appSecret, options.accessTokenSecret);
const serviceData = {
accessToken: options.accessToken,
expiresAt: +new Date() + 1000 * parseInt(options.expiresIn, 10),
};
const fields = _.pick(identity, whitelistedFields);
_.extend(serviceData, fields);
return {
serviceData,
options: {
profile: {
name: identity.name,
},
},
};
});

View File

@@ -0,0 +1,115 @@
import type { OAuthConfiguration } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import { ServiceConfiguration } from 'meteor/service-configuration';
import passport from 'passport';
import _ from 'underscore';
import { addPassportCustomOAuth } from '../oauth/addPassportCustomOAuth';
import { CustomOAuth } from './custom-oauth/custom_oauth_server';
import { settings } from '../../settings';
const config: Partial<OAuthConfiguration> = {
serverURL: '',
identityPath: '/oauth/me',
addAutopublishFields: {
forLoggedInUser: ['services.wordpress'],
forOtherUsers: ['services.wordpress.user_login'],
},
accessTokenParam: 'access_token',
};
const serviceKey = 'wordpress';
const WordPress = new CustomOAuth(serviceKey, config);
const fillSettings = _.debounce(async (): Promise<void> => {
config.serverURL = settings.get('API_Wordpress_URL');
if (!config.serverURL) {
if (config.serverURL === undefined) {
return fillSettings();
}
return;
}
delete config.identityPath;
delete config.identityTokenSentVia;
delete config.authorizePath;
delete config.tokenPath;
delete config.scope;
passport.unuse(serviceKey);
const serverType = settings.get('Accounts_OAuth_Wordpress_server_type');
switch (serverType) {
case 'custom':
if (settings.get('Accounts_OAuth_Wordpress_identity_path')) {
config.identityPath = settings.get('Accounts_OAuth_Wordpress_identity_path');
}
if (settings.get('Accounts_OAuth_Wordpress_identity_token_sent_via')) {
config.identityTokenSentVia = settings.get('Accounts_OAuth_Wordpress_identity_token_sent_via');
}
if (settings.get('Accounts_OAuth_Wordpress_token_path')) {
config.tokenPath = settings.get('Accounts_OAuth_Wordpress_token_path');
}
if (settings.get('Accounts_OAuth_Wordpress_authorize_path')) {
config.authorizePath = settings.get('Accounts_OAuth_Wordpress_authorize_path');
}
if (settings.get('Accounts_OAuth_Wordpress_scope')) {
config.scope = settings.get('Accounts_OAuth_Wordpress_scope');
}
break;
case 'wordpress-com':
config.identityPath = '/rest/v1/me';
config.identityTokenSentVia = 'header' as OAuthConfiguration['identityTokenSentVia'];
config.authorizePath = '/oauth2/authorize';
config.tokenPath = '/oauth2/token';
config.scope = 'auth';
break;
default:
config.identityPath = '/oauth/me';
config.authorizePath = '/oauth/authorize';
config.tokenPath = '/oauth/token';
break;
}
const clientId = settings.get<string>('Accounts_OAuth_Wordpress_id');
const clientSecret = settings.get<string>('Accounts_OAuth_Wordpress_secret');
if (!clientId || !clientSecret) {
return;
}
const completeConfig = { ...config, clientId, clientSecret };
if (settings.get<boolean>('Accounts_OAuth_Use_Modern_Flow')) {
addPassportCustomOAuth(serviceKey, completeConfig);
} else {
WordPress.configure(completeConfig);
}
const enabled = settings.get('Accounts_OAuth_Wordpress');
if (enabled) {
await ServiceConfiguration.configurations.upsertAsync(
{
service: serviceKey,
},
{
$set: config,
},
);
} else {
await ServiceConfiguration.configurations.removeAsync({
service: serviceKey,
});
}
}, 1000);
Meteor.startup(() => {
settings.watchByRegex(/(API\_Wordpress\_URL)?(Accounts\_OAuth\_Wordpress\_)?/, () => fillSettings());
settings.watch('Accounts_OAuth_Use_Modern_Flow', () => fillSettings());
});

View File

@@ -0,0 +1,27 @@
import type { MeteorError } from '@rocket.chat/core-services';
import type { IUser, IMethodConnection } from '@rocket.chat/core-typings';
interface IMethodArgument {
user?: { username: string };
password?: {
digest: string;
algorithm: string;
};
resume?: string;
cas?: boolean;
totp?: {
code: string;
};
}
export interface ILoginAttempt {
type: string;
allowed: boolean;
methodName: string;
methodArguments: IMethodArgument[];
connection: IMethodConnection;
user?: IUser;
error?: MeteorError;
}

View File

@@ -0,0 +1,31 @@
import generator from 'generate-password';
import { passwordPolicy } from './passwordPolicy';
export const generatePassword = (): string => {
const policies = passwordPolicy.getPasswordPolicy();
const maxLength: number = (policies.policy.find(([key]) => key === 'get-password-policy-maxLength')?.[1]?.maxLength as number) || -1;
const minLength: number = (policies.policy.find(([key]) => key === 'get-password-policy-minLength')?.[1]?.minLength as number) || -1;
const length = Math.min(Math.max(minLength, 12), maxLength > 0 ? maxLength : Number.MAX_SAFE_INTEGER);
if (policies.enabled) {
for (let i = 0; i < 10; i++) {
const password = generator.generate({
length,
...(policies.policy && { numbers: true }),
...(policies.policy.some(([key]) => key === 'get-password-policy-mustContainAtLeastOneSpecialCharacter') && { symbols: true }),
...(policies.policy.some(([key]) => key === 'get-password-policy-mustContainAtLeastOneLowercase') && { lowercase: true }),
...(policies.policy.some(([key]) => key === 'get-password-policy-mustContainAtLeastOneUppercase') && { uppercase: true }),
strict: true,
});
if (passwordPolicy.validate(password)) {
return password;
}
}
}
return generator.generate({ length: 17 });
};

View File

@@ -0,0 +1,37 @@
import type { ILoginAttempt } from './ILoginAttempt';
import { settings } from '../../settings';
import { SystemLogger } from '../logger/system';
export const logFailedLoginAttempts = (login: ILoginAttempt): void => {
if (!settings.get('Login_Logs_Enabled')) {
return;
}
let user = 'unknown';
if (login.methodArguments[0]?.user?.username && settings.get('Login_Logs_Username')) {
user = login.methodArguments[0]?.user?.username;
}
const { connection } = login;
let { clientAddress } = connection;
if (!settings.get('Login_Logs_ClientIp')) {
clientAddress = '-';
}
let forwardedFor = connection.httpHeaders?.['x-forwarded-for'];
let realIp = connection.httpHeaders?.['x-real-ip'];
if (!settings.get('Login_Logs_ForwardedForIp')) {
forwardedFor = '-';
realIp = '-';
}
let userAgent = connection.httpHeaders?.['user-agent'];
if (!settings.get('Login_Logs_UserAgent')) {
userAgent = '-';
}
SystemLogger.info({
msg: 'Failed login detected',
user,
clientAddress,
forwardedFor,
realIp,
userAgent,
});
};

View File

@@ -0,0 +1,16 @@
// Do not disclose if user exists when password is invalid
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
const { _runLoginHandlers } = Accounts;
Accounts._options.ambiguousErrorMessages = true;
Accounts._runLoginHandlers = async function (methodInvocation, options) {
const result = await _runLoginHandlers.call(Accounts, methodInvocation, options);
if (result.error instanceof Meteor.Error) {
result.error = new Meteor.Error(401, 'User not found');
}
return result;
};

View File

@@ -0,0 +1,69 @@
import type { IOAuthApps, IUser } from '@rocket.chat/core-typings';
import { OAuthApps, Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { Meteor } from 'meteor/meteor';
import { parseUriList } from './parseUriList';
import type { OauthAppsAddParams } from '../../../api/v1/oauthapps';
import { hasPermissionAsync } from '../../authorization/hasPermission';
export async function addOAuthApp(applicationParams: OauthAppsAddParams, uid: IUser['_id'] | undefined): Promise<IOAuthApps> {
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'addOAuthApp' });
}
const user = await Users.findOneById(uid, { projection: { username: 1 } });
if (!user?.username) {
// TODO: username is required, but not always present
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'addOAuthApp' });
}
if (!(await hasPermissionAsync(uid, 'manage-oauth-apps'))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'addOAuthApp' });
}
if (!applicationParams.name || typeof applicationParams.name.valueOf() !== 'string' || applicationParams.name.trim() === '') {
throw new Meteor.Error('error-invalid-name', 'Invalid name', { method: 'addOAuthApp' });
}
if (
!applicationParams.redirectUri ||
typeof applicationParams.redirectUri.valueOf() !== 'string' ||
applicationParams.redirectUri.trim() === ''
) {
throw new Meteor.Error('error-invalid-redirectUri', 'Invalid redirectUri', {
method: 'addOAuthApp',
});
}
if (typeof applicationParams.active !== 'boolean') {
throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', {
method: 'addOAuthApp',
});
}
const application = {
...applicationParams,
redirectUri: parseUriList(applicationParams.redirectUri),
clientId: Random.id(),
clientSecret: Random.secret(),
_createdAt: new Date(),
_updatedAt: new Date(),
_createdBy: {
_id: user._id,
username: user.username,
},
};
if (application.redirectUri.length === 0) {
throw new Meteor.Error('error-invalid-redirectUri', 'Invalid redirectUri', {
method: 'addOAuthApp',
});
}
return {
...application,
_id: (await OAuthApps.insertOne(application)).insertedId,
};
}

View File

@@ -0,0 +1,4 @@
import './oauth2-server';
import './addOAuthApp';
import '../../../meteor-methods/auth/updateOAuthApp';
import '../../../meteor-methods/auth/deleteOAuthApp';

View File

@@ -0,0 +1,85 @@
import type { IUser } from '@rocket.chat/core-typings';
import { OAuthAccessTokens, Users } from '@rocket.chat/models';
import type { Request, Response } from 'express';
import type express from 'express';
import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
import { isPlainObject } from '../../../../lib/utils/isPlainObject';
import { API } from '../../../api';
import { OAuth2Server } from '../../../oauth2-server/oauth';
const oauth2server = new OAuth2Server({
// If you're developing something related to oauth servers, you should change this to true
debug: false,
});
// https://github.com/RocketChat/rocketchat-oauth2-server/blob/e758fd7ef69348c7ceceabe241747a986c32d036/model.coffee#L27-L27
async function getAccessToken(accessToken: string) {
return OAuthAccessTokens.findOneByAccessToken(accessToken);
}
export async function oAuth2ServerAuth(partialRequest: { authorization?: string; accessToken?: string }): Promise<IUser | undefined> {
const headerToken = partialRequest.authorization?.replace('Bearer ', '');
const incomingToken = headerToken || partialRequest.accessToken;
if (!incomingToken) {
return;
}
const accessToken = await getAccessToken(incomingToken);
// If there is no token available or the token has expired, return undefined
if (!accessToken || (accessToken.expires != null && accessToken.expires < new Date())) {
return;
}
const user = await Users.findOneActiveById(accessToken.userId);
if (!user) {
return;
}
return user;
}
oauth2server.app.disable('x-powered-by');
oauth2server.app.get('/oauth/userinfo', async (req: Request, res: Response) => {
if (req.headers.authorization == null) {
return res.status(401).send('No token');
}
const accessToken = req.headers.authorization.replace('Bearer ', '');
const token = await getAccessToken(accessToken);
if (token == null) {
return res.status(401).send('Invalid Token');
}
const user = await Users.findOneActiveById(token.userId);
if (!user) {
return res.status(401).send('Invalid Token');
}
return res.send({
sub: user._id,
name: user.name,
email: user.emails?.[0].address,
email_verified: user.emails?.[0].verified,
department: '',
birthdate: '',
preffered_username: user.username,
updated_at: user._updatedAt,
picture: `${Meteor.absoluteUrl()}avatar/${user.username}`,
});
});
API.v1.addAuthMethod((routeContext) => {
const authorization = routeContext.request.headers.get('authorization') ?? undefined;
const query = isPlainObject(routeContext.queryParams) ? routeContext.queryParams : {};
const accessToken = typeof query.access_token === 'string' ? query.access_token : undefined;
if (routeContext.queryParams && 'access_token' in routeContext.queryParams) {
delete routeContext.queryParams.access_token;
}
return oAuth2ServerAuth({ authorization, accessToken });
});
(WebApp.connectHandlers as unknown as ReturnType<typeof express>).use(oauth2server.app);

View File

@@ -0,0 +1,17 @@
export const parseUriList = (userUri: string) => {
if (userUri.indexOf('\n') < 0 && userUri.indexOf(',') < 0) {
return userUri;
}
const uriList: string[] = [];
userUri.split(/[,\n]/).forEach((item) => {
const uri = item.trim();
if (uri === '') {
return;
}
uriList.push(uri);
});
return uriList.join(','); // TODO: This is a hack because the original code was returning a string or an array of strings
};

View File

@@ -0,0 +1,64 @@
import { PasswordPolicy } from '@rocket.chat/password-policies';
import { settings } from '../../settings';
const enabled = false;
const minLength = -1;
const maxLength = -1;
const forbidRepeatingCharacters = false;
const forbidRepeatingCharactersCount = 3;
const mustContainAtLeastOneLowercase = false;
const mustContainAtLeastOneUppercase = false;
const mustContainAtLeastOneNumber = false;
const mustContainAtLeastOneSpecialCharacter = false;
export let passwordPolicy = new PasswordPolicy({
enabled,
minLength,
maxLength,
forbidRepeatingCharacters,
forbidRepeatingCharactersCount,
mustContainAtLeastOneLowercase,
mustContainAtLeastOneUppercase,
mustContainAtLeastOneNumber,
mustContainAtLeastOneSpecialCharacter,
throwError: true,
});
settings.watchMultiple(
[
'Accounts_Password_Policy_Enabled',
'Accounts_Password_Policy_MinLength',
'Accounts_Password_Policy_MaxLength',
'Accounts_Password_Policy_ForbidRepeatingCharacters',
'Accounts_Password_Policy_ForbidRepeatingCharactersCount',
'Accounts_Password_Policy_AtLeastOneLowercase',
'Accounts_Password_Policy_AtLeastOneUppercase',
'Accounts_Password_Policy_AtLeastOneNumber',
'Accounts_Password_Policy_AtLeastOneSpecialCharacter',
],
([
enabled,
minLength,
maxLength,
forbidRepeatingCharacters,
forbidRepeatingCharactersCount,
mustContainAtLeastOneLowercase,
mustContainAtLeastOneUppercase,
mustContainAtLeastOneNumber,
mustContainAtLeastOneSpecialCharacter,
]) => {
passwordPolicy = new PasswordPolicy({
enabled: Boolean(enabled),
minLength: Number(minLength),
maxLength: Number(maxLength),
forbidRepeatingCharacters: Boolean(forbidRepeatingCharacters),
forbidRepeatingCharactersCount: Number(forbidRepeatingCharactersCount),
mustContainAtLeastOneLowercase: Boolean(mustContainAtLeastOneLowercase),
mustContainAtLeastOneUppercase: Boolean(mustContainAtLeastOneUppercase),
mustContainAtLeastOneNumber: Boolean(mustContainAtLeastOneNumber),
mustContainAtLeastOneSpecialCharacter: Boolean(mustContainAtLeastOneSpecialCharacter),
throwError: true,
});
},
);

View File

@@ -0,0 +1,173 @@
import type { IServerEvent } from '@rocket.chat/core-typings';
import { ServerEventType } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { Rooms, ServerEvents, Users } from '@rocket.chat/models';
import { addMinutesToADate } from '../../../lib/utils/addMinutesToADate';
import { settings } from '../../settings';
import { getClientAddress } from '../getClientAddress';
import type { ILoginAttempt } from './ILoginAttempt';
import { sendMessage } from '../messages/sendMessage';
const logger = new Logger('LoginProtection');
const notifyFailedLogin = async (ipOrUsername: string, blockedUntil: Date, failedAttempts: number): Promise<void> => {
const channelToNotify = settings.get<string>('Block_Multiple_Failed_Logins_Notify_Failed_Channel');
if (!channelToNotify) {
logger.error('Cannot notify failed logins: channel provided is invalid');
return;
}
// verify channel exists
// to avoid issues when "fname" is presented in the UI, check if the name matches it as well
const room = await Rooms.findOneByNameOrFname(channelToNotify);
if (!room) {
logger.error({ msg: 'Cannot notify failed logins: channel provided does not exist', channelToNotify });
return;
}
const rocketCat = await Users.findOneById('rocket.cat');
// send message
const message = {
attachments: [
{
fields: [
{
title: 'Failed login attempt threshold exceeded',
value: `User or IP: ${ipOrUsername}\nBlocked until: ${blockedUntil}\nFailed Attempts: ${failedAttempts}`,
short: true,
},
],
color: 'red',
},
],
};
await sendMessage(rocketCat, message, room);
};
export const isValidLoginAttemptByIp = async (ip: string): Promise<boolean> => {
const whitelist = String(settings.get('Block_Multiple_Failed_Logins_Ip_Whitelist')).split(',');
if (
!settings.get('Block_Multiple_Failed_Logins_Enabled') ||
!settings.get('Block_Multiple_Failed_Logins_By_Ip') ||
whitelist.includes(ip)
) {
return true;
}
// misconfigured
const attemptsUntilBlock = settings.get<number>('Block_Multiple_Failed_Logins_Attempts_Until_Block_By_Ip');
if (!attemptsUntilBlock) {
return true;
}
// if user never failed to login, then it's valid
const lastFailedAttemptAt = (await ServerEvents.findLastFailedAttemptByIp(ip))?.ts;
if (!lastFailedAttemptAt) {
return true;
}
const minutesUntilUnblock = settings.get<number>('Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes');
const lockoutTimeStart = addMinutesToADate(new Date(), minutesUntilUnblock * -1);
const lastSuccessfulAttemptAt = (await ServerEvents.findLastSuccessfulAttemptByIp(ip))?.ts;
// successful logins should reset the counter
const startTime = lastSuccessfulAttemptAt
? new Date(Math.max(lockoutTimeStart.getTime(), lastSuccessfulAttemptAt.getTime()))
: lockoutTimeStart;
const failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByIpSince(ip, startTime);
// if user didn't reach the threshold, then it's valid
if (failedAttemptsSinceLastLogin < attemptsUntilBlock) {
return true;
}
if (settings.get('Block_Multiple_Failed_Logins_Notify_Failed')) {
const willBeBlockedUntil = addMinutesToADate(new Date(lastFailedAttemptAt), minutesUntilUnblock);
await notifyFailedLogin(ip, willBeBlockedUntil, failedAttemptsSinceLastLogin);
}
return false;
};
export const isValidAttemptByUser = async (login: ILoginAttempt): Promise<boolean> => {
if (!settings.get('Block_Multiple_Failed_Logins_Enabled') || !settings.get('Block_Multiple_Failed_Logins_By_User')) {
return true;
}
const loginUsername = login.methodArguments[0].user?.username;
if (!loginUsername) {
return true;
}
// misconfigured
const attemptsUntilBlock = settings.get<number>('Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User');
if (!attemptsUntilBlock) {
return true;
}
// if user never failed to login, then it's valid
const lastFailedAttemptAt = (await ServerEvents.findLastFailedAttemptByUsername(loginUsername))?.ts;
if (!lastFailedAttemptAt) {
return true;
}
const minutesUntilUnblock = settings.get<number>('Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes');
const lockoutTimeStart = addMinutesToADate(new Date(), minutesUntilUnblock * -1);
const lastSuccessfulAttemptAt = (await ServerEvents.findLastSuccessfulAttemptByUsername(loginUsername))?.ts;
// succesful logins should reset the counter
const startTime = lastSuccessfulAttemptAt
? new Date(Math.max(lockoutTimeStart.getTime(), lastSuccessfulAttemptAt.getTime()))
: lockoutTimeStart;
// get total failed attempts during the lockout time
const failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByUsernameSince(loginUsername, startTime);
// if user didn't reach the threshold, then it's valid
if (failedAttemptsSinceLastLogin < attemptsUntilBlock) {
return true;
}
if (settings.get('Block_Multiple_Failed_Logins_Notify_Failed')) {
const willBeBlockedUntil = addMinutesToADate(new Date(lastFailedAttemptAt), minutesUntilUnblock);
await notifyFailedLogin(loginUsername, willBeBlockedUntil, failedAttemptsSinceLastLogin);
}
return false;
};
export const saveFailedLoginAttempts = async (login: ILoginAttempt): Promise<void> => {
const user: IServerEvent['u'] = {
_id: login.user?._id,
username: login.user?.username || login.methodArguments[0].user?.username,
};
await ServerEvents.insertOne({
ip: getClientAddress(login.connection),
t: ServerEventType.FAILED_LOGIN_ATTEMPT,
ts: new Date(),
u: user,
});
};
export const saveSuccessfulLogin = async (login: ILoginAttempt): Promise<void> => {
const user: IServerEvent['u'] = {
_id: login.user?._id,
username: login.user?.username || login.methodArguments[0].user?.username,
};
await ServerEvents.insertOne({
ip: getClientAddress(login.connection),
t: ServerEventType.LOGIN,
ts: new Date(),
u: user,
});
};

View File

@@ -0,0 +1,531 @@
import { Apps, AppEvents } from '@rocket.chat/apps';
import { User } from '@rocket.chat/core-services';
import { Roles, Settings, Users } from '@rocket.chat/models';
import { escapeRegExp, escapeHTML, getLoginExpirationInDays, removeEmpty } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import _ from 'underscore';
import { isValidAttemptByUser, isValidLoginAttemptByIp } from './restrictLoginAttempts';
import { parseCSV } from '../../../lib/utils/parseCSV';
import { safeHtmlDots } from '../../../lib/utils/safeHtmlDots';
import { getNewUserRoles } from '../../services/user/lib/getNewUserRoles';
import { settings } from '../../settings';
import { callbacks } from '../callbacks';
import { beforeCreateUserCallback } from '../callbacks/beforeCreateUserCallback';
import { getClientAddress } from '../getClientAddress';
import { getMaxLoginTokens } from '../getMaxLoginTokens';
import { i18n } from '../i18n';
import * as Mailer from '../notifications/email/api';
import { notifyOnSettingChangedById } from '../notifyListener';
import { addUserRolesAsync } from '../roles/addUserRoles';
import { joinDefaultChannels } from '../rooms/joinDefaultChannels';
import { getAvatarSuggestionForUser } from '../users/getAvatarSuggestionForUser';
import { setAvatarFromServiceWithValidation } from '../users/setUserAvatar';
import { getBaseUserFields } from '../utils/functions/getBaseUserFields';
Accounts.config({
forbidClientAccountCreation: true,
});
/**
* Accounts calls `_initServerPublications` and holds the `_defaultPublishFields`, without Object.assign its not possible
* to extend the projection
*
* the idea is to send all required fields to the client during login
* we tried `defaultFieldsSelector` , but it changes all Meteor.userAsync projections which is undesirable
*
*
* we are removing the status here because meteor send 'offline'
*/
Object.assign(Accounts._defaultPublishFields.projection, (({ status, ...rest }) => rest)(getBaseUserFields(true)));
// Override Meteor's _expireTokens to ensure the correct login expiration is used.
// If loginExpirationInDays is not set (e.g., startup was disrupted before settings watcher fired),
// read the setting directly from MongoDB before proceeding with token cleanup.
// This prevents tokens from being incorrectly deleted using Meteor's 90-day default.
const { _expireTokens } = Accounts;
Accounts._expireTokens = async function (oldestValidDate, userId) {
if (!Accounts._options.loginExpirationInDays) {
const loginExpiration = await Settings.getValueById('Accounts_LoginExpiration');
Accounts._options.loginExpirationInDays = getLoginExpirationInDays(loginExpiration);
}
return _expireTokens.call(Accounts, oldestValidDate, userId);
};
Meteor.startup(() => {
settings.watchMultiple(['Accounts_LoginExpiration', 'Site_Name', 'From_Email'], () => {
Accounts._options.loginExpirationInDays = getLoginExpirationInDays(settings.get('Accounts_LoginExpiration'));
Accounts.emailTemplates.siteName = settings.get('Site_Name');
Accounts.emailTemplates.from = `${settings.get('Site_Name')} <${settings.get('From_Email')}>`;
});
});
Accounts.emailTemplates.userToActivate = {
subject() {
const subject = i18n.t('Accounts_Admin_Email_Approval_Needed_Subject_Default');
const siteName = settings.get('Site_Name');
return `[${siteName}] ${subject}`;
},
html(options = {}) {
const email = options.reason
? 'Accounts_Admin_Email_Approval_Needed_With_Reason_Default'
: 'Accounts_Admin_Email_Approval_Needed_Default';
return Mailer.replace(i18n.t(email), {
name: escapeHTML(options.name),
email: escapeHTML(options.email),
reason: escapeHTML(options.reason),
});
},
};
Accounts.emailTemplates.userActivated = {
subject({ active, username }) {
const activated = username ? 'Activated' : 'Approved';
const action = active ? activated : 'Deactivated';
const subject = `Accounts_Email_${action}_Subject`;
const siteName = settings.get('Site_Name');
return `[${siteName}] ${i18n.t(subject)}`;
},
html({ active, name, username }) {
const activated = username ? 'Activated' : 'Approved';
const action = active ? activated : 'Deactivated';
return Mailer.replace(i18n.t(`Accounts_Email_${action}`), {
name: escapeHTML(name),
});
},
};
let verifyEmailTemplate = '';
let enrollAccountTemplate = '';
let resetPasswordTemplate = '';
Meteor.startup(() => {
Mailer.getTemplateWrapped('Verification_Email', (value) => {
verifyEmailTemplate = value;
});
Mailer.getTemplateWrapped('Accounts_Enrollment_Email', (value) => {
enrollAccountTemplate = value;
});
Mailer.getTemplateWrapped('Forgot_Password_Email', (value) => {
resetPasswordTemplate = value;
});
});
Accounts.emailTemplates.verifyEmail.html = function (userModel, url) {
const name = safeHtmlDots(userModel.name);
return Mailer.replace(verifyEmailTemplate, { Verification_Url: url, name });
};
Accounts.emailTemplates.verifyEmail.subject = function () {
const subject = settings.get('Verification_Email_Subject');
return Mailer.replace(subject || '');
};
Accounts.urls.resetPassword = function (token) {
return Meteor.absoluteUrl(`reset-password/${token}`);
};
Accounts.emailTemplates.resetPassword.subject = function (userModel) {
return Mailer.replace(settings.get('Forgot_Password_Email_Subject') || '', {
name: userModel.name,
});
};
Accounts.emailTemplates.resetPassword.html = function (userModel, url) {
return Mailer.replacekey(
Mailer.replace(resetPasswordTemplate, {
name: userModel.name,
}),
'Forgot_Password_Url',
url,
);
};
Accounts.emailTemplates.enrollAccount.subject = function (user) {
const subject = settings.get('Accounts_Enrollment_Email_Subject');
return Mailer.replace(subject, user);
};
Accounts.emailTemplates.enrollAccount.html = function (user = {} /* , url*/) {
return Mailer.replace(enrollAccountTemplate, {
name: escapeHTML(user.name),
email: user.emails && user.emails[0] && escapeHTML(user.emails[0].address),
});
};
const getLinkedInName = ({ firstName, lastName }) => {
const { preferredLocale, localized: firstNameLocalized } = firstName;
const { localized: lastNameLocalized } = lastName;
// LinkedIn new format
if (preferredLocale && firstNameLocalized && preferredLocale.language && preferredLocale.country) {
const locale = `${preferredLocale.language}_${preferredLocale.country}`;
if (firstNameLocalized[locale] && lastNameLocalized[locale]) {
return `${firstNameLocalized[locale]} ${lastNameLocalized[locale]}`;
}
if (firstNameLocalized[locale]) {
return firstNameLocalized[locale];
}
}
// LinkedIn old format
if (!lastName) {
return firstName;
}
return `${firstName} ${lastName}`;
};
const validateEmailDomain = (user) => {
if (user.type === 'visitor') {
return true;
}
let domainWhiteList = settings.get('Accounts_AllowedDomainsList');
if (_.isEmpty(domainWhiteList?.trim())) {
return true;
}
domainWhiteList = domainWhiteList.split(',').map((domain) => domain.trim());
if (user.emails && user.emails.length > 0) {
const email = user.emails[0].address;
const inWhiteList = domainWhiteList.some((domain) => email.match(`@${escapeRegExp(domain)}$`));
if (!inWhiteList) {
throw new Meteor.Error('error-invalid-domain');
}
}
return true;
};
const onCreateUserAsync = async function (options, user = {}) {
if (!options.skipBeforeCreateUserCallback) {
await beforeCreateUserCallback.run(options, user);
}
user.status = 'offline';
user.active = user.active !== undefined ? user.active : !settings.get('Accounts_ManuallyApproveNewUsers');
if (settings.get('Accounts_ManuallyApproveNewUsers') && !user.active) {
user.inactiveReason = 'pending_approval';
}
if (!user.name) {
if (options.profile) {
if (options.profile.name) {
user.name = options.profile.name;
} else if (options.profile.firstName) {
// LinkedIn format
user.name = getLinkedInName(options.profile);
}
}
}
if (user.services) {
const verified = settings.get('Accounts_Verify_Email_For_External_Accounts');
for (const service of Object.values(user.services)) {
const suggestedName = service.name || service.username;
if (!user.name && suggestedName) {
user.name = suggestedName;
}
if (!user.emails && service.email) {
user.emails = [
{
address: service.email,
verified,
},
];
}
}
}
if (!options.skipAdminEmail && !user.active) {
const destinations = [];
const usersInRole = await Roles.findUsersInRole('admin');
await usersInRole.forEach((adminUser) => {
if (Array.isArray(adminUser.emails)) {
adminUser.emails.forEach((email) => {
destinations.push(`${adminUser.name}<${email.address}>`);
});
}
});
const email = {
to: destinations,
from: settings.get('From_Email'),
subject: Accounts.emailTemplates.userToActivate.subject(),
html: Accounts.emailTemplates.userToActivate.html({
...options,
name: options.name || options.profile?.name,
email: options.email || user.emails[0].address,
}),
};
await Mailer.send(email);
}
if (!options.skipOnCreateUserCallback) {
await callbacks.run('onCreateUser', options, user);
}
if (!options.skipEmailValidation && !validateEmailDomain(user)) {
throw new Meteor.Error(403, 'User validation failed');
}
return removeEmpty(user);
};
Accounts.onCreateUser(function (...args) {
// Depends on meteor support for Async
return onCreateUserAsync.call(this, ...args);
});
const { insertUserDoc } = Accounts;
Accounts.insertUserDoc = async function (options, user) {
const globalRoles = new Set();
if (Match.test(options.globalRoles, [String]) && options.globalRoles.length > 0) {
options.globalRoles.map((role) => globalRoles.add(role));
}
if (Match.test(user.globalRoles, [String]) && user.globalRoles.length > 0) {
user.globalRoles.map((role) => globalRoles.add(role));
}
delete user.globalRoles;
// for some reason, the name is not being set in the user object but is being set in the options object
if (options.name && typeof options.name === 'string') {
user.name = options.name;
}
if (user.services && !user.services.password && !options.skipAuthServiceDefaultRoles) {
const defaultAuthServiceRoles = parseCSV(settings.get('Accounts_Registration_AuthenticationServices_Default_Roles') || '');
if (defaultAuthServiceRoles.length > 0) {
defaultAuthServiceRoles.map((role) => globalRoles.add(role));
}
}
const arrayGlobalRoles = [...globalRoles];
const roles = options.skipNewUserRolesSetting ? arrayGlobalRoles : getNewUserRoles(arrayGlobalRoles);
if (!user.type) {
user.type = 'user';
}
if (
settings.get('Accounts_TwoFactorAuthentication_Enabled') &&
settings.get('Accounts_TwoFactorAuthentication_By_Email_Enabled') &&
settings.get('Accounts_TwoFactorAuthentication_By_Email_Auto_Opt_In')
) {
user.services = user.services || {};
user.services.email2fa = {
enabled: true,
changedAt: new Date(),
};
}
// Make sure that the user has the field 'roles'
if (!user.roles) {
user.roles = [];
}
const _id = await insertUserDoc.call(Accounts, options, user);
user = await Users.findOne({
_id,
});
/**
* if settings shows setup wizard to be pending
* and no admin's been found,
* and existing role list doesn't include admin
* create this user admin.
* count this as the completion of setup wizard step 1.
*/
if (!options.skipAdminCheck) {
const hasAdmin = await Users.findOneByRolesAndType('admin', 'user', { projection: { _id: 1 } });
if (!roles.includes('admin') && !hasAdmin) {
roles.push('admin');
if (settings.get('Show_Setup_Wizard') === 'pending') {
// TODO: audit
(await Settings.updateValueById('Show_Setup_Wizard', 'in_progress')).modifiedCount &&
void notifyOnSettingChangedById('Show_Setup_Wizard');
}
}
}
await addUserRolesAsync(_id, roles);
// Make user's roles to be present on callback
user = await Users.findOneById(_id, { projection: { username: 1, type: 1, roles: 1 } });
if (user.username) {
if (options.joinDefaultChannels !== false) {
await joinDefaultChannels(_id, options.joinDefaultChannelsSilenced);
}
if (!options.skipAfterCreateUserCallback && user.type !== 'visitor') {
setImmediate(() => {
return callbacks.run('afterCreateUser', user);
});
}
if (!options.skipDefaultAvatar && settings.get('Accounts_SetDefaultAvatar') === true) {
const avatarSuggestions = await getAvatarSuggestionForUser(user);
for (const service of Object.keys(avatarSuggestions)) {
const avatarData = avatarSuggestions[service];
if (service !== 'gravatar') {
await setAvatarFromServiceWithValidation(_id, avatarData.blob, '', service);
break;
}
}
}
}
if (!options.skipAppsEngineEvent) {
// `post` triggered events don't need to wait for the promise to resolve
Apps.self?.triggerEvent(AppEvents.IPostUserCreated, { user, performedBy: options.performedBy }).catch((e) => {
Apps.self?.getRocketChatLogger().error({ msg: 'Error while executing post user created event', err: e });
});
}
return _id;
};
const validateLoginAttemptAsync = async function (login) {
login = await callbacks.run('beforeValidateLogin', login);
if (!(await isValidLoginAttemptByIp(getClientAddress(login.connection)))) {
throw new Meteor.Error('error-login-blocked-for-ip', 'Login has been temporarily blocked For IP', {
function: 'Accounts.validateLoginAttempt',
});
}
if (!(await isValidAttemptByUser(login))) {
throw new Meteor.Error('error-login-blocked-for-user', 'Login has been temporarily blocked For User', {
function: 'Accounts.validateLoginAttempt',
});
}
if (login.allowed !== true) {
return login.allowed;
}
if (login.user.type === 'visitor') {
return true;
}
if (login.user.type === 'app') {
throw new Meteor.Error('error-app-user-is-not-allowed-to-login', 'App user is not allowed to login', {
function: 'Accounts.validateLoginAttempt',
});
}
if (!!login.user.active !== true) {
throw new Meteor.Error('error-user-is-not-activated', 'User is not activated', {
function: 'Accounts.validateLoginAttempt',
});
}
if (!login.user.roles || !Array.isArray(login.user.roles)) {
throw new Meteor.Error('error-user-has-no-roles', 'User has no roles', {
function: 'Accounts.validateLoginAttempt',
});
}
if (login.user.roles.includes('admin') === false && login.type === 'password' && settings.get('Accounts_EmailVerification') === true) {
const validEmail = login.user.emails.filter((email) => email.verified === true);
if (validEmail.length === 0) {
throw new Meteor.Error('error-invalid-email', 'Invalid email __email__');
}
}
login = await callbacks.run('onValidateLogin', login);
await Users.updateLastLoginById(login.user._id);
setImmediate(() => {
return callbacks.run('afterValidateLogin', login);
});
/**
* Trigger the event only when the
* user does login in Rocket.chat
*/
if (login.type !== 'resume') {
// App IPostUserLoggedIn event hook
await Apps.self?.triggerEvent(AppEvents.IPostUserLoggedIn, login.user);
}
return true;
};
Accounts.validateLoginAttempt(function (...args) {
// Depends on meteor support for Async
return validateLoginAttemptAsync.call(this, ...args);
});
Accounts.validateNewUser((user) => {
if (user.type === 'visitor') {
return true;
}
if (
settings.get('Accounts_Registration_AuthenticationServices_Enabled') === false &&
settings.get('LDAP_Enable') === false &&
!(user.services && user.services.password)
) {
throw new Meteor.Error('registration-disabled-authentication-services', 'User registration is disabled for authentication services');
}
return true;
});
Accounts.validateNewUser((user) => {
if (user.type === 'visitor') {
return true;
}
let domainWhiteList = settings.get('Accounts_AllowedDomainsList');
if (_.isEmpty(domainWhiteList?.trim())) {
return true;
}
domainWhiteList = domainWhiteList.split(',').map((domain) => domain.trim());
if (user.emails && user.emails.length > 0) {
const email = user.emails[0].address;
const inWhiteList = domainWhiteList.some((domain) => email.match(`@${escapeRegExp(domain)}$`));
if (inWhiteList === false) {
throw new Meteor.Error('error-invalid-domain');
}
}
return true;
});
Accounts.onLogin(async ({ user }) => {
if (!user || !user.services || !user.services.resume || !user.services.resume.loginTokens || !user._id) {
return;
}
if (user.services.resume.loginTokens.length < getMaxLoginTokens()) {
return;
}
await User.ensureLoginTokensLimit(user._id);
});

View File

@@ -0,0 +1,23 @@
import { Users } from '@rocket.chat/models';
import { Accounts } from 'meteor/accounts-base';
import { check } from 'meteor/check';
Accounts.registerLoginHandler('login-token', async (result) => {
if (!result.loginToken) {
return;
}
check(result.loginToken, String);
const user = await Users.findOne({
'services.loginToken.token': result.loginToken,
});
if (user) {
await Users.updateOne({ _id: user._id }, { $unset: { 'services.loginToken': 1 } });
return {
userId: user._id,
};
}
});

View File

@@ -0,0 +1,41 @@
Supports role or permission based authorization, and defines the association between them.
A user is associated with role(s), and a role is associated with permission(s). This package depends on alanning:roles for the role/user association, while the role/permission association is handled internally. Thus, the underlying alanning:roles has no concept of a permission or the association between a role and permission.
Authorization checks can be done based on a role or permission. However, permission based checks are preferred because they loosely associate an action with a role. For example:
```
# permission based check
if hasPermission(userId, 'edit-message') ...
# action is loosely associated to role via permission. Thus action can be revoked
# at runtime by removing the permission for user's role instead of modifying the action code.
# role based check
if hasRole(userId, 'admin')
if hasAnyRole(userId, ['admin','site-moderator','moderator'])
# action is statically associated with the role
# action code has to be modified to add/remove role authorization
```
Usage:
```
# assign user to admin role. Permissions scoped globally
RocketChat.authz.addUserRoles(userId, ['admin'])
# assign user to moderator role. Permissions scoped to the specified room
# user can moderate (e.g. edit channel name, delete private group message) for only one room specified by the roomId
RocketChat.authz.addUserRoles(userId, ['moderator'], roomId )
# check if user can modify message for any room
RocketChat.authz.hasPermission(userId, 'edit-message')
# check if user can modify message for the specified room. Also returns true if user
# has 'edit-message' at global scope.
RocketChat.authz.hasPermission(userId, 'edit-message', roomId)
```
Notes:
1. Roles are statically defined. UI needs to be implemented to dynamically assign permission(s) to a Role
2. 'admin', 'moderator', 'user' role identifiers should NOT be changed (unless you update the associated code) because they are referenced when creating users and creating rooms.
3. edit, delete message permissions are at either the global or room scope. i.e. role with edit-message with GLOBAL scope can edit ANY message regardless of the room type. However, role with edit-message with room scope can only edit messages for the room. The global scope is associated with the admin role while the "room-scoped" permission is assigned to the room "moderator" (room creator). If we want a middle ground that allows for edit-message for only channel/group/direct, then we need to create individual edit-c-message, edit-p-message, edit-d-message permissions.

View File

@@ -0,0 +1,12 @@
import { Authorization } from '@rocket.chat/core-services';
export const canAccessRoomAsync = Authorization.canAccessRoom;
export const canAccessRoomIdAsync = Authorization.canAccessRoomId;
export const canAccessRoomIdsAsync = Authorization.canAccessRoomIds;
export const roomAccessAttributes = {
_id: 1,
t: 1,
teamId: 1,
prid: 1,
abacAttributes: 1,
};

View File

@@ -0,0 +1,77 @@
import type { IUser, IRoom } from '@rocket.chat/core-typings';
import { Rooms } from '@rocket.chat/models';
import { canAccessRoomAsync } from './canAccessRoom';
import { hasPermissionAsync } from './hasPermission';
import { getValue } from '../../settings/raw';
const elapsedTime = (ts: Date): number => {
const dif = Date.now() - ts.getTime();
return Math.round(dif / 1000 / 60);
};
export const canDeleteMessageAsync = async (
deletingUser: Pick<IUser, '_id' | 'username'>,
{ u, rid, ts }: { u: Pick<IUser, '_id' | 'username'>; rid: string; ts?: Date },
): Promise<boolean> => {
const room = await Rooms.findOneById<Pick<IRoom, '_id' | 'ro' | 'unmuted' | 't' | 'teamId' | 'prid'>>(rid, {
projection: {
_id: 1,
ro: 1,
unmuted: 1,
t: 1,
teamId: 1,
prid: 1,
},
});
if (!room) {
return false;
}
if (!(await canAccessRoomAsync(room, { _id: deletingUser._id }))) {
return false;
}
const forceDelete = await hasPermissionAsync(deletingUser._id, 'force-delete-message', rid);
if (forceDelete) {
return true;
}
if (!ts) {
return false;
}
const deleteAllowed = await getValue('Message_AllowDeleting');
if (!deleteAllowed) {
return false;
}
const allowedToDeleteAny = await hasPermissionAsync(deletingUser._id, 'delete-message', rid);
const allowed =
allowedToDeleteAny || (deletingUser._id === u._id && (await hasPermissionAsync(deletingUser._id, 'delete-own-message', rid)));
if (!allowed) {
return false;
}
const bypassBlockTimeLimit = await hasPermissionAsync(deletingUser._id, 'bypass-time-limit-edit-and-delete', rid);
if (!bypassBlockTimeLimit) {
const blockDeleteInMinutes = await getValue('Message_AllowDeleting_BlockDeleteInMinutes');
if (blockDeleteInMinutes) {
const timeElapsedForMessage = elapsedTime(ts);
return timeElapsedForMessage <= blockDeleteInMinutes;
}
}
if (room.ro === true && !(await hasPermissionAsync(deletingUser._id, 'post-readonly', rid))) {
// Unless the user was manually unmuted
if (deletingUser.username && !(room.unmuted || []).includes(deletingUser.username)) {
throw new Error("You can't delete messages because the room is readonly.");
}
}
return true;
};

View File

@@ -0,0 +1,66 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { Subscriptions, Rooms } from '@rocket.chat/models';
import { canAccessRoomAsync } from './canAccessRoom';
import { hasPermissionAsync } from './hasPermission';
import { RoomMemberActions } from '../../../definition/IRoomTypeConfig';
import { roomCoordinator } from '../rooms/roomCoordinator';
const subscriptionOptions = {
projection: {
blocked: 1,
blocker: 1,
},
};
// TODO: remove option uid and username and type
export async function validateRoomMessagePermissionsAsync(
room: IRoom | null,
args: { uid: IUser['_id']; username: IUser['username']; type: IUser['type'] } | IUser,
extraData?: Record<string, any>,
): Promise<void> {
if (!room) {
throw new Error('error-invalid-room');
}
if (room.archived) {
throw new Error('room_is_archived');
}
if (args.type !== 'app' && !(await canAccessRoomAsync(room, 'uid' in args ? { _id: args.uid } : args, extraData))) {
throw new Error('error-not-allowed');
}
if (
await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.BLOCK, 'uid' in args ? args.uid : args._id)
) {
const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, 'uid' in args ? args.uid : args._id, subscriptionOptions);
if (subscription && (subscription.blocked || subscription.blocker)) {
throw new Error('room_is_blocked');
}
}
if (room.ro === true && !(await hasPermissionAsync('uid' in args ? args.uid : args._id, 'post-readonly', room._id))) {
// Unless the user was manually unmuted
if (args.username && !(room.unmuted || []).includes(args.username)) {
throw new Error("You can't send messages because the room is readonly.");
}
}
if (args.username && room?.muted?.includes(args.username)) {
throw new Error('You_have_been_muted');
}
}
// TODO: remove option uid and username and type
export async function canSendMessageAsync(
rid: IRoom['_id'],
user: { uid: IUser['_id']; username: IUser['username']; type: IUser['type'] } | IUser,
extraData?: Record<string, any>,
): Promise<IRoom> {
const room = await Rooms.findOneById(rid);
if (!room) {
throw new Error('error-invalid-room');
}
await validateRoomMessagePermissionsAsync(room, user, extraData);
return room;
}

View File

@@ -0,0 +1,242 @@
// Note:
// 1.if we need to create a role that can only edit channel message, but not edit group message
// then we can define edit-<type>-message instead of edit-message
// 2. admin, moderator, and user roles should not be deleted as they are referenced in the code.
export const permissions = [
{ _id: 'access-permissions', roles: ['admin'] },
{ _id: 'access-marketplace', roles: ['admin', 'user'] },
{ _id: 'access-setting-permissions', roles: ['admin'] },
{ _id: 'add-oauth-service', roles: ['admin'] },
{ _id: 'add-user-to-joined-room', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'add-user-to-any-c-room', roles: ['admin'] },
{ _id: 'add-user-to-any-p-room', roles: [] },
{ _id: 'kick-user-from-any-c-room', roles: ['admin'] },
{ _id: 'kick-user-from-any-p-room', roles: [] },
{ _id: 'api-bypass-rate-limit', roles: ['admin', 'bot', 'app'] },
{ _id: 'archive-room', roles: ['admin', 'owner'] },
{ _id: 'assign-admin-role', roles: ['admin'] },
{ _id: 'assign-roles', roles: ['admin'] },
{ _id: 'ban-user', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'bulk-register-user', roles: ['admin'] },
{ _id: 'change-livechat-room-visitor', roles: ['admin', 'livechat-manager', 'livechat-agent'] },
{ _id: 'create-c', roles: ['admin', 'user', 'federated-external', 'bot', 'app'] },
{ _id: 'create-d', roles: ['admin', 'user', 'federated-external', 'bot', 'app'] },
{ _id: 'create-p', roles: ['admin', 'user', 'federated-external', 'bot', 'app'] },
{ _id: 'create-personal-access-tokens', roles: ['admin', 'user'] },
{ _id: 'create-user', roles: ['admin'] },
{ _id: 'clean-channel-history', roles: ['admin'] },
{ _id: 'delete-c', roles: ['admin', 'owner'] },
{ _id: 'delete-d', roles: ['admin'] },
{ _id: 'delete-message', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'delete-own-message', roles: ['admin', 'user', 'federated-external'] },
{ _id: 'delete-p', roles: ['admin', 'owner'] },
{ _id: 'delete-user', roles: ['admin'] },
{ _id: 'edit-message', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'edit-other-user-active-status', roles: ['admin'] },
{ _id: 'edit-other-user-info', roles: ['admin'] },
{ _id: 'edit-other-user-password', roles: ['admin'] },
{ _id: 'edit-other-user-avatar', roles: ['admin'] },
{ _id: 'edit-other-user-e2ee', roles: ['admin'] },
{ _id: 'edit-other-user-totp', roles: ['admin'] },
{ _id: 'edit-privileged-setting', roles: ['admin'] },
{ _id: 'edit-room', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'edit-room-avatar', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'edit-room-retention-policy', roles: ['admin'] },
{ _id: 'force-delete-message', roles: ['admin', 'owner'] },
{ _id: 'join-without-join-code', roles: ['admin', 'bot', 'app'] },
{ _id: 'leave-c', roles: ['admin', 'user', 'federated-external', 'bot', 'anonymous', 'app'] },
{ _id: 'leave-p', roles: ['admin', 'user', 'federated-external', 'bot', 'anonymous', 'app'] },
{ _id: 'logout-other-user', roles: ['admin'] },
{ _id: 'manage-assets', roles: ['admin'] },
{ _id: 'manage-email-inbox', roles: ['admin'] },
{ _id: 'manage-emoji', roles: ['admin'] },
{ _id: 'manage-user-status', roles: ['admin'] },
{ _id: 'manage-outgoing-integrations', roles: ['admin'] },
{ _id: 'manage-incoming-integrations', roles: ['admin'] },
{ _id: 'manage-own-outgoing-integrations', roles: ['admin'] },
{ _id: 'manage-own-incoming-integrations', roles: ['admin'] },
{ _id: 'manage-oauth-apps', roles: ['admin'] },
{ _id: 'manage-selected-settings', roles: ['admin'] },
{ _id: 'mention-all', roles: ['admin', 'owner', 'moderator', 'user', 'federated-external'] },
{ _id: 'mention-here', roles: ['admin', 'owner', 'moderator', 'user', 'federated-external'] },
{ _id: 'mute-user', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'remove-user', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'run-import', roles: ['admin'] },
{ _id: 'run-migration', roles: ['admin'] },
{ _id: 'set-moderator', roles: ['admin', 'owner'] },
{ _id: 'set-owner', roles: ['admin', 'owner'] },
{ _id: 'send-many-messages', roles: ['admin', 'bot', 'app'] },
{ _id: 'set-leader', roles: ['admin', 'owner'] },
{ _id: 'start-discussion', roles: ['admin', 'user', 'federated-external', 'guest', 'app'] },
{ _id: 'start-discussion-other-user', roles: ['admin', 'user', 'federated-external', 'owner', 'app'] },
{ _id: 'unarchive-room', roles: ['admin'] },
{ _id: 'view-c-room', roles: ['admin', 'user', 'federated-external', 'bot', 'app', 'anonymous'] },
{ _id: 'user-generate-access-token', roles: ['admin'] },
{ _id: 'view-d-room', roles: ['admin', 'user', 'federated-external', 'bot', 'app', 'guest'] },
{ _id: 'view-device-management', roles: ['admin'] },
{ _id: 'view-engagement-dashboard', roles: ['admin'] },
{ _id: 'view-full-other-user-info', roles: ['admin'] },
{ _id: 'view-joined-room', roles: ['guest', 'bot', 'app', 'anonymous'] },
{ _id: 'view-join-code', roles: ['admin'] },
{ _id: 'view-logs', roles: ['admin'] },
{ _id: 'view-other-user-channels', roles: ['admin'] },
{ _id: 'view-p-room', roles: ['admin', 'user', 'federated-external', 'anonymous', 'guest'] },
{ _id: 'view-privileged-setting', roles: ['admin'] },
{ _id: 'view-room-administration', roles: ['admin'] },
{ _id: 'view-statistics', roles: ['admin'] },
{ _id: 'view-user-administration', roles: ['admin'] },
{ _id: 'preview-c-room', roles: ['admin', 'user', 'federated-external', 'anonymous'] },
{ _id: 'view-outside-room', roles: ['admin', 'owner', 'moderator', 'user', 'federated-external'] },
{ _id: 'view-broadcast-member-list', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'call-management', roles: ['admin', 'owner', 'moderator', 'user'] },
{ _id: 'create-invite-links', roles: ['admin', 'owner', 'moderator'] },
{
_id: 'view-l-room',
roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'],
},
{
_id: 'create-livechat-contact',
roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'],
},
{
_id: 'update-livechat-contact',
roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'],
},
{
_id: 'view-livechat-contact',
roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'],
},
{
_id: 'delete-livechat-contact',
roles: ['livechat-manager', 'admin'],
},
{
_id: 'view-livechat-contact-history',
roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'],
},
{ _id: 'view-livechat-manager', roles: ['livechat-manager', 'livechat-monitor', 'admin'] },
{
_id: 'view-omnichannel-contact-center',
roles: ['livechat-manager', 'livechat-agent', 'livechat-monitor', 'admin'],
},
{ _id: 'edit-omnichannel-contact', roles: ['livechat-manager', 'livechat-agent', 'admin'] },
{ _id: 'view-livechat-rooms', roles: ['livechat-manager', 'livechat-monitor', 'admin'] },
{
_id: 'close-livechat-room',
roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'],
},
{ _id: 'close-others-livechat-room', roles: ['livechat-manager', 'livechat-monitor', 'admin'] },
{
_id: 'on-hold-livechat-room',
roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'],
},
{
_id: 'on-hold-others-livechat-room',
roles: ['livechat-manager', 'livechat-monitor', 'admin'],
},
{ _id: 'save-others-livechat-room-info', roles: ['livechat-manager', 'livechat-monitor', 'admin'] },
{
_id: 'remove-closed-livechat-rooms',
roles: ['livechat-manager', 'livechat-monitor', 'admin'],
},
{ _id: 'view-livechat-analytics', roles: ['livechat-manager', 'livechat-monitor', 'admin'] },
{
_id: 'view-livechat-queue',
roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'],
},
{ _id: 'transfer-livechat-guest', roles: ['livechat-manager', 'livechat-monitor', 'admin'] },
{ _id: 'manage-livechat-managers', roles: ['livechat-manager', 'admin'] },
{ _id: 'manage-livechat-agents', roles: ['livechat-manager', 'livechat-monitor', 'admin'] },
{
_id: 'manage-livechat-departments',
roles: ['livechat-manager', 'livechat-monitor', 'admin'],
},
{ _id: 'view-livechat-departments', roles: ['livechat-manager', 'livechat-monitor', 'admin'] },
{
_id: 'add-livechat-department-agents',
roles: ['livechat-manager', 'livechat-monitor', 'admin'],
},
{
_id: 'view-livechat-real-time-monitoring',
roles: ['livechat-manager', 'livechat-monitor', 'admin'],
},
{ _id: 'view-livechat-triggers', roles: ['livechat-manager', 'admin'] },
{ _id: 'view-livechat-customfields', roles: ['livechat-manager', 'admin'] },
{ _id: 'view-livechat-installation', roles: ['livechat-manager', 'admin'] },
{ _id: 'view-livechat-appearance', roles: ['livechat-manager', 'admin'] },
{ _id: 'view-livechat-webhooks', roles: ['livechat-manager', 'admin'] },
{
_id: 'view-livechat-business-hours',
roles: ['livechat-manager', 'livechat-monitor', 'admin'],
},
{
_id: 'view-livechat-room-closed-same-department',
roles: ['livechat-manager', 'livechat-monitor', 'admin'],
},
{
_id: 'view-livechat-room-closed-by-another-agent',
roles: ['livechat-manager', 'livechat-monitor', 'admin'],
},
{
_id: 'view-livechat-room-customfields',
roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'],
},
{
_id: 'edit-livechat-room-customfields',
roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'],
},
{ _id: 'send-omnichannel-chat-transcript', roles: ['livechat-manager', 'admin'] },
{ _id: 'mail-messages', roles: ['admin'] },
{ _id: 'toggle-room-e2e-encryption', roles: ['owner', 'admin'] },
{ _id: 'message-impersonate', roles: ['bot', 'app'] },
{ _id: 'create-team', roles: ['admin', 'user'] },
{ _id: 'delete-team', roles: ['admin', 'owner'] },
{ _id: 'convert-team', roles: ['admin', 'owner'] },
{ _id: 'edit-team', roles: ['admin', 'owner'] },
{ _id: 'add-team-member', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'edit-team-member', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'move-room-to-team', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'create-team-channel', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'create-team-group', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'delete-team-channel', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'delete-team-group', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'edit-team-channel', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'remove-team-channel', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'view-all-team-channels', roles: ['admin', 'owner'] },
{ _id: 'view-all-teams', roles: ['admin'] },
{ _id: 'remove-closed-livechat-room', roles: ['livechat-manager', 'admin'] },
{ _id: 'remove-livechat-department', roles: ['livechat-manager', 'admin'] },
// New Media calls permissions
{ _id: 'allow-internal-voice-calls', roles: ['admin', 'user'] },
{ _id: 'allow-external-voice-calls', roles: ['admin', 'user'] },
{ _id: 'remove-livechat-department', roles: ['livechat-manager', 'admin'] },
{ _id: 'manage-apps', roles: ['admin'] },
{ _id: 'post-readonly', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'set-readonly', roles: ['admin', 'owner'] },
{ _id: 'set-react-when-readonly', roles: ['admin', 'owner'] },
{ _id: 'manage-cloud', roles: ['admin'] },
{ _id: 'manage-sounds', roles: ['admin'] },
{ _id: 'access-mailer', roles: ['admin'] },
{ _id: 'pin-message', roles: ['owner', 'moderator', 'admin'] },
{ _id: 'mobile-upload-file', roles: ['user', 'federated-external', 'admin'] },
{ _id: 'send-mail', roles: ['admin'] },
{ _id: 'view-federation-data', roles: ['admin'] },
{ _id: 'access-federation', roles: ['admin', 'user', 'federated-external'] },
{ _id: 'add-all-to-room', roles: ['admin'] },
{ _id: 'get-server-info', roles: ['admin'] },
{ _id: 'register-on-cloud', roles: ['admin'] },
{ _id: 'test-admin-options', roles: ['admin'] },
{ _id: 'test-push-notifications', roles: ['admin', 'user'] },
{ _id: 'sync-auth-services-users', roles: ['admin'] },
{ _id: 'restart-server', roles: ['admin'] },
{ _id: 'remove-slackbridge-links', roles: ['admin'] },
{ _id: 'view-import-operations', roles: ['admin'] },
{ _id: 'clear-oembed-cache', roles: ['admin'] },
{ _id: 'videoconf-ring-users', roles: ['admin', 'owner', 'moderator', 'user'] },
{ _id: 'view-moderation-console', roles: ['admin'] },
{ _id: 'manage-moderation-actions', roles: ['admin'] },
{ _id: 'bypass-time-limit-edit-and-delete', roles: ['bot', 'app'] },
{ _id: 'export-messages-as-pdf', roles: ['admin', 'user'] },
];

View File

@@ -0,0 +1,7 @@
import type { IRole } from '@rocket.chat/core-typings';
import { Roles } from '@rocket.chat/models';
export const getRoles = async (): Promise<IRole[]> => Roles.find().toArray();
export const getRoleIds = async (): Promise<IRole['_id'][]> =>
(await Roles.find({}, { projection: { _id: 1 } }).toArray()).map(({ _id }) => _id);

View File

@@ -0,0 +1,51 @@
import type { IRole, IUser } from '@rocket.chat/core-typings';
import type { FindPaginated } from '@rocket.chat/model-typings';
import { Roles, Subscriptions, Users } from '@rocket.chat/models';
import { compact } from 'lodash';
import type { Document, FindCursor, FindOptions } from 'mongodb';
export function getUsersInRole(roleId: IRole['_id'], scope?: string): Promise<FindCursor<IUser>>;
export function getUsersInRole(roleId: IRole['_id'], scope: string | undefined, options: FindOptions<IUser>): Promise<FindCursor<IUser>>;
export function getUsersInRole<P extends Document = IUser>(
roleId: IRole['_id'],
scope: string | undefined,
options: FindOptions<P extends IUser ? IUser : P>,
): Promise<FindCursor<P extends IUser ? IUser : P>>;
export function getUsersInRole<P = IUser>(
roleId: IRole['_id'],
scope: string | undefined,
options?: any | undefined,
): Promise<FindCursor<IUser | P>> {
// TODO move the code from Roles.findUsersInRole to here and change all places to use this function
return Roles.findUsersInRole(roleId, scope, options);
}
export async function getUsersInRolePaginated(
roleId: IRole['_id'],
scope: string | undefined,
options?: any | undefined,
): Promise<FindPaginated<FindCursor<IUser>>> {
if (process.env.NODE_ENV === 'development' && (scope === 'Users' || scope === 'Subscriptions')) {
throw new Error('Roles.findUsersInRole method received a role scope instead of a scope value.');
}
const role = await Roles.findOneById<Pick<IRole, '_id' | 'scope'>>(roleId, { projection: { scope: 1 } });
if (!role) {
throw new Error('role not found');
}
switch (role.scope) {
case 'Subscriptions':
const subscriptions = await Subscriptions.findByRolesAndRoomId({ roles: role._id, rid: scope }, { projection: { 'u._id': 1 } })
.map((subscription) => subscription.u?._id)
.toArray();
return Users.findPaginated({ _id: { $in: compact(subscriptions) } }, options || {});
case 'Users':
default:
return Users.findPaginatedUsersInRoles([role._id], options);
}
}

View File

@@ -0,0 +1,24 @@
import { Authorization } from '@rocket.chat/core-services';
import type { UserWithRoles } from '@rocket.chat/core-services';
import type { IUser, IPermission, IRoom } from '@rocket.chat/core-typings';
// Forward only the fields the permission check needs, so a full user document
// (with services, e2e keys, etc.) isn't serialized to the authorization service.
const toSubject = (user: IUser['_id'] | UserWithRoles): IUser['_id'] | UserWithRoles =>
typeof user === 'string' ? user : { _id: user._id, roles: user.roles };
export const hasAllPermissionAsync = async (
user: IUser['_id'] | UserWithRoles,
permissions: IPermission['_id'][],
scope?: IRoom['_id'],
): Promise<boolean> => Authorization.hasAllPermission(toSubject(user), permissions, scope);
export const hasPermissionAsync = async (
user: IUser['_id'] | UserWithRoles,
permissionId: IPermission['_id'],
scope?: IRoom['_id'],
): Promise<boolean> => Authorization.hasPermission(toSubject(user), permissionId, scope);
export const hasAtLeastOnePermissionAsync = async (
user: IUser['_id'] | UserWithRoles,
permissions: IPermission['_id'][],
scope?: IRoom['_id'],
): Promise<boolean> => Authorization.hasAtLeastOnePermission(toSubject(user), permissions, scope);

View File

@@ -0,0 +1,31 @@
import type { IRole, IUser, IRoom, ISubscription } from '@rocket.chat/core-typings';
import { Roles } from '@rocket.chat/models';
/**
* @deprecated use `Authorization.hasAnyRole` instead
*/
export const hasAnyRoleAsync = async (
userId: IUser['_id'],
roleIds: IRole['_id'][],
scope?: IRoom['_id'] | undefined,
): Promise<boolean> => {
if (!Array.isArray(roleIds)) {
throw new Error('error-invalid-arguments');
}
if (!userId || userId === '') {
return false;
}
return Roles.isUserInRoles(userId, roleIds, scope);
};
export const hasRoleAsync = async (userId: IUser['_id'], roleId: IRole['_id'], scope?: IRoom['_id'] | undefined): Promise<boolean> => {
if (Array.isArray(roleId)) {
throw new Error('error-invalid-arguments');
}
return hasAnyRoleAsync(userId, [roleId], scope);
};
export const subscriptionHasRole = (sub: ISubscription, role: IRole['_id']): boolean | undefined => sub.roles?.includes(role);

View File

@@ -0,0 +1,7 @@
import { roomAccessAttributes, canAccessRoomAsync } from './canAccessRoom';
import { getRoles } from './getRoles';
import { getUsersInRole } from './getUsersInRole';
import { subscriptionHasRole } from './hasRole';
import './streamer/permissions';
export { getRoles, getUsersInRole, subscriptionHasRole, canAccessRoomAsync, roomAccessAttributes };

View File

@@ -0,0 +1,14 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { settings } from '../../settings';
export const isABACManagedRoom = (room: Pick<IRoom, 't' | 'abacAttributes'>): boolean =>
room.t === 'p' && settings.get<boolean>('ABAC_Enabled') && Array.isArray(room.abacAttributes) && room.abacAttributes.length > 0;
export const stripABACManagedFieldsForAdmin = <T extends Pick<IRoom, 't' | 'abacAttributes'>>(room: T): T => {
if (!isABACManagedRoom(room)) {
return room;
}
const { announcement, topic, description, ...rest } = room as T & Pick<IRoom, 'announcement' | 'topic' | 'description'>;
return rest as T;
};

View File

@@ -0,0 +1,83 @@
import { License } from '@rocket.chat/core-services';
import { Permissions, Roles } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from './hasPermission';
import { CONSTANTS, AuthorizationUtils } from '../../../app/authorization/lib';
import { notifyOnPermissionChangedById } from '../notifyListener';
export const addPermissionToRoleMethod = async (uid: string, permissionId: string, role: string): Promise<void> => {
if (role === 'guest' && !AuthorizationUtils.hasRestrictionsToRole(role) && (await License.hasValidLicense())) {
AuthorizationUtils.addRolePermissionWhiteList(role, await License.getGuestPermissions());
}
if (AuthorizationUtils.isPermissionRestrictedForRole(permissionId, role)) {
throw new Meteor.Error('error-action-not-allowed', 'Permission is restricted', {
method: 'authorization:addPermissionToRole',
action: 'Adding_permission',
});
}
const permission = await Permissions.findOneById(permissionId);
if (!permission) {
throw new Meteor.Error('error-invalid-permission', 'Permission does not exist', {
method: 'authorization:addPermissionToRole',
action: 'Adding_permission',
});
}
if (!(await Roles.findOneById(role, { projection: { _id: 1 } }))) {
throw new Meteor.Error('error-invalid-role', 'Role does not exist', {
method: 'authorization:addPermissionToRole',
action: 'Adding_permission',
});
}
if (
!(await hasPermissionAsync(uid, 'access-permissions')) ||
(permission.level === CONSTANTS.SETTINGS_LEVEL && !(await hasPermissionAsync(uid, 'access-setting-permissions')))
) {
throw new Meteor.Error('error-action-not-allowed', 'Adding permission is not allowed', {
method: 'authorization:addPermissionToRole',
action: 'Adding_permission',
});
}
if (permission.groupPermissionId) {
await Permissions.addRole(permission.groupPermissionId, role);
void notifyOnPermissionChangedById(permission.groupPermissionId);
}
await Permissions.addRole(permission._id, role);
void notifyOnPermissionChangedById(permission._id);
};
export const removeRoleFromPermissionMethod = async (uid: string, permissionId: string, role: string): Promise<void> => {
const permission = await Permissions.findOneById(permissionId);
if (!permission) {
throw new Meteor.Error('error-permission-not-found', 'Permission not found', {
method: 'authorization:removeRoleFromPermission',
});
}
if (
!(await hasPermissionAsync(uid, 'access-permissions')) ||
(permission.level === CONSTANTS.SETTINGS_LEVEL && !(await hasPermissionAsync(uid, 'access-setting-permissions')))
) {
throw new Meteor.Error('error-action-not-allowed', 'Removing permission is not allowed', {
method: 'authorization:removeRoleFromPermission',
action: 'Removing_permission',
});
}
if (permission.groupPermissionId) {
await Permissions.removeRole(permission.groupPermissionId, role);
void notifyOnPermissionChangedById(permission.groupPermissionId);
}
await Permissions.removeRole(permission._id, role);
void notifyOnPermissionChangedById(permission._id);
};

View File

@@ -0,0 +1,40 @@
import type { IPermission, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Permissions } from '@rocket.chat/models';
import { check, Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import type { WithId } from 'mongodb';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'permissions/get'(
updatedSince?: Date,
): Promise<IPermission[] | { update: IPermission[]; remove: WithId<RocketChatRecordDeleted<IPermission>>[] }>;
}
}
export const permissionsGetMethod = async (
updatedAt?: Date,
): Promise<IPermission[] | { update: IPermission[]; remove: WithId<RocketChatRecordDeleted<IPermission>>[] }> => {
const records = await Permissions.find(updatedAt && { _updatedAt: { $gt: updatedAt } }).toArray();
if (updatedAt instanceof Date) {
return {
update: records,
remove: await Permissions.trashFindDeletedAfter(updatedAt, {}, { projection: { _id: 1, _deletedAt: 1 } }).toArray(),
};
}
return records;
};
Meteor.methods<ServerMethods>({
async 'permissions/get'(updatedAt?: Date) {
check(updatedAt, Match.Maybe(Date));
// TODO: should we return this for non logged users?
// TODO: we could cache this collection
return permissionsGetMethod(updatedAt);
},
});

View File

@@ -0,0 +1,123 @@
/* eslint no-multi-spaces: 0 */
import type { IPermission, ISetting } from '@rocket.chat/core-typings';
import { Permissions, Settings } from '@rocket.chat/models';
import { permissions } from './constant/permissions';
import { getSettingPermissionId, CONSTANTS } from '../../../app/authorization/lib';
import { settings } from '../../settings';
import { createOrUpdateProtectedRoleAsync } from '../roles/createOrUpdateProtectedRole';
export const upsertPermissions = async (): Promise<void> => {
for (const permission of permissions) {
await Permissions.create(permission._id, permission.roles);
}
const defaultRoles = [
{ name: 'admin', scope: 'Users', description: 'Admin' },
{ name: 'moderator', scope: 'Subscriptions', description: 'Moderator' },
{ name: 'leader', scope: 'Subscriptions', description: 'Leader' },
{ name: 'owner', scope: 'Subscriptions', description: 'Owner' },
{ name: 'user', scope: 'Users', description: '' },
{ name: 'federated-external', scope: 'Users', description: '' },
{ name: 'bot', scope: 'Users', description: '' },
{ name: 'app', scope: 'Users', description: '' },
{ name: 'guest', scope: 'Users', description: '' },
{ name: 'anonymous', scope: 'Users', description: '' },
{ name: 'livechat-agent', scope: 'Users', description: 'Livechat Agent' },
{ name: 'livechat-manager', scope: 'Users', description: 'Livechat Manager' },
] as const;
for (const role of defaultRoles) {
await createOrUpdateProtectedRoleAsync(role.name, role);
}
const getPreviousPermissions = async function (settingId?: string): Promise<Record<string, IPermission>> {
const previousSettingPermissions: {
[key: string]: IPermission;
} = {};
await Permissions.findByLevel('settings', settingId).forEach((permission: IPermission) => {
previousSettingPermissions[permission._id] = permission;
});
return previousSettingPermissions;
};
const createSettingPermission = async function (
setting: ISetting,
previousSettingPermissions: {
[key: string]: IPermission;
},
): Promise<void> {
const permissionId = getSettingPermissionId(setting._id);
const permission: Omit<IPermission, '_id' | '_updatedAt'> = {
level: CONSTANTS.SETTINGS_LEVEL as 'settings' | undefined,
// copy those setting-properties which are needed to properly publish the setting-based permissions
settingId: setting._id,
// TODO: migrate settings with group and section with null to undefined
...(setting.group && { group: setting.group }),
...(setting.section && { section: setting.section }),
sorter: setting.sorter,
roles: [],
};
// copy previously assigned roles if available
if (previousSettingPermissions[permissionId]?.roles) {
permission.roles = previousSettingPermissions[permissionId].roles;
}
if (setting.group) {
permission.groupPermissionId = getSettingPermissionId(setting.group);
}
if (setting.section) {
permission.sectionPermissionId = getSettingPermissionId(setting.section);
}
const existent = await Permissions.findOne(
{
_id: permissionId,
...permission,
},
{ projection: { _id: 1 } },
);
if (!existent) {
try {
await Permissions.updateOne({ _id: permissionId }, { $set: permission }, { upsert: true });
} catch (e) {
if (!(e as Error).message.includes('E11000')) {
// E11000 refers to a MongoDB error that can occur when using unique indexes for upserts
// https://docs.mongodb.com/manual/reference/method/db.collection.update/#use-unique-indexes
await Permissions.updateOne({ _id: permissionId }, { $set: permission }, { upsert: true });
}
}
}
delete previousSettingPermissions[permissionId];
};
const createPermissionsForExistingSettings = async function (): Promise<void> {
const previousSettingPermissions = await getPreviousPermissions();
const settings = await Settings.findNotHidden().toArray();
for await (const setting of settings) {
await createSettingPermission(setting, previousSettingPermissions);
}
// remove permissions for non-existent settings
for (const obsoletePermission of Object.keys(previousSettingPermissions)) {
if (previousSettingPermissions.hasOwnProperty(obsoletePermission)) {
await Permissions.deleteOne({ _id: obsoletePermission });
}
}
};
// for each setting which already exists, create a permission to allow changing just this one setting
await createPermissionsForExistingSettings();
// register a callback for settings for be create in higher-level-packages
settings.on('*', async ([settingId]) => {
const previousSettingPermissions = await getPreviousPermissions(settingId);
const setting = await Settings.findOneById(settingId);
if (setting && !setting.hidden) {
await createSettingPermission(setting, previousSettingPermissions);
}
});
};

View File

@@ -0,0 +1,5 @@
# Rocket.Chat Auto Translate
Rocket.Chat supports auto translate through Google Cloud Translation API
Through this you will be able to read messages in your native languages
regardless of language the message was written on.

View File

@@ -0,0 +1,387 @@
import type {
IMessage,
IRoom,
MessageAttachment,
IProviderMetadata,
ISupportedLanguage,
ITranslationResult,
} from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { Messages, Subscriptions } from '@rocket.chat/models';
import { escapeHTML, isTruthy } from '@rocket.chat/tools';
import { Meteor } from 'meteor/meteor';
import _ from 'underscore';
import { settings } from '../../settings';
import { callbacks } from '../callbacks';
import { Markdown } from '../messaging/markdown';
import { notifyOnMessageChange } from '../notifyListener';
const translationLogger = new Logger('AutoTranslate');
// Constant tokenizer patterns, compiled once instead of on every message.
// Safe to share with /g flags because String.prototype.replace resets
// lastIndex on each call.
const urlSchemes = 'http,https';
const markdownLinkRegex = new RegExp(`(!?\\[)([^\\]]+)(\\]\\((?:${urlSchemes}):\\/\\/[^\\)]+\\))`, 'gm');
const pipedLinkRegex = new RegExp(`((?:<|&lt;)(?:${urlSchemes}):\\/\\/[^\\|]+\\|)(.+?)(?=>|&gt;)((?:>|&gt;))`, 'gm');
const wrappedParagraphRegex = new RegExp('^\\s*<p>|</p>\\s*$', 'gm');
const Providers = Symbol('Providers');
const Provider = Symbol('Provider');
/**
* This class allows translation providers to
* register,load and also returns the active provider.
*/
export class TranslationProviderRegistry {
static [Providers]: { [k: string]: AutoTranslate } = {};
static enabled = false;
static [Provider]: string | null = null;
/**
* Registers the translation provider into the registry.
* @param {*} provider
*/
static registerProvider(provider: AutoTranslate): void {
// get provider information
const metadata = provider._getProviderMetadata();
if (!metadata) {
translationLogger.error('Provider metadata is not defined');
return;
}
TranslationProviderRegistry[Providers][metadata.name] = provider;
}
/**
* Return the active Translation provider
*/
static getActiveProvider(): AutoTranslate | null {
if (!TranslationProviderRegistry.enabled) {
return null;
}
const provider = TranslationProviderRegistry[Provider];
if (!provider) {
return null;
}
return TranslationProviderRegistry[Providers][provider];
}
static async getSupportedLanguages(target: string): Promise<ISupportedLanguage[] | undefined> {
return TranslationProviderRegistry.enabled ? TranslationProviderRegistry.getActiveProvider()?.getSupportedLanguages(target) : undefined;
}
static async translateMessage(message: IMessage, room: IRoom, targetLanguage?: string): Promise<IMessage | null> {
if (!TranslationProviderRegistry.enabled) {
return null;
}
const provider = TranslationProviderRegistry.getActiveProvider();
if (!provider) {
return null;
}
return provider.translateMessage(message, { room, targetLanguage });
}
static getProviders(): AutoTranslate[] {
return Object.values(TranslationProviderRegistry[Providers]);
}
static setCurrentProvider(provider: string): void {
TranslationProviderRegistry[Provider] = provider;
}
static setEnable(enabled: boolean): void {
TranslationProviderRegistry.enabled = enabled;
TranslationProviderRegistry.registerCallbacks();
}
static registerCallbacks(): void {
if (!TranslationProviderRegistry.enabled) {
callbacks.remove('afterSaveMessage', 'autotranslate');
return;
}
callbacks.add(
'afterSaveMessage',
(message, { room }) => TranslationProviderRegistry.translateMessage(message, room),
callbacks.priority.MEDIUM,
'autotranslate',
);
}
}
/**
* Generic auto translate base implementation.
* This class provides generic parts of implementation for
* tokenization, detokenization, call back register and unregister.
* @abstract
* @class
*/
export abstract class AutoTranslate {
name: string;
languages: string[];
supportedLanguages: {
[language: string]: ISupportedLanguage[];
};
/**
* Encapsulate the api key and provider settings.
* @constructor
*/
constructor() {
this.name = '';
this.languages = [];
this.supportedLanguages = {};
}
/**
* Extracts non-translatable parts of a message
* @param {object} message
* @return {object} message
*/
tokenize(message: IMessage): IMessage {
if (!message.tokens || !Array.isArray(message.tokens)) {
message.tokens = [];
}
message = this.tokenizeEmojis(message);
message = this.tokenizeCode(message);
message = this.tokenizeURLs(message);
message = this.tokenizeMentions(message);
return message;
}
tokenizeEmojis(message: IMessage): IMessage {
let count = message.tokens?.length || 0;
message.msg = message.msg.replace(/:[+\w\d]+:/g, (match) => {
const token = `<i class=notranslate>{${count++}}</i>`;
message.tokens?.push({
token,
text: match,
});
return token;
});
return message;
}
tokenizeURLs(message: IMessage): IMessage {
let count = message.tokens?.length || 0;
// Support ![alt text](http://image url) and [text](http://link)
message.msg = message.msg.replace(markdownLinkRegex, (_match, pre, text, post) => {
const pretoken = `<i class=notranslate>{${count++}}</i>`;
message.tokens?.push({
token: pretoken,
text: pre,
});
const posttoken = `<i class=notranslate>{${count++}}</i>`;
message.tokens?.push({
token: posttoken,
text: post,
});
return pretoken + text + posttoken;
});
// Support <http://link|Text>
message.msg = message.msg.replace(pipedLinkRegex, (_match, pre, text, post) => {
const pretoken = `<i class=notranslate>{${count++}}</i>`;
message.tokens?.push({
token: pretoken,
text: pre,
});
const posttoken = `<i class=notranslate>{${count++}}</i>`;
message.tokens?.push({
token: posttoken,
text: post,
});
return pretoken + text + posttoken;
});
return message;
}
tokenizeCode(message: IMessage): IMessage {
let count = message.tokens?.length || 0;
message.html = message.msg;
message = Markdown.parseMessageNotEscaped(message);
// Some parsers (e. g. Marked) wrap the complete message in a <p> - this is unnecessary and should be ignored with respect to translations
message.msg = message.msg.replace(wrappedParagraphRegex, '');
for (const [tokenIndex, value] of message.tokens?.entries() ?? []) {
const { token } = value;
if (token.indexOf('notranslate') === -1) {
const newToken = `<i class=notranslate>{${count++}}</i>`;
message.msg = message.msg.replace(token, newToken);
message.tokens ? (message.tokens[tokenIndex].token = newToken) : undefined;
}
}
return message;
}
tokenizeMentions(message: IMessage): IMessage {
let count = message.tokens?.length || 0;
if (message.mentions && message.mentions.length > 0) {
message.mentions.forEach((mention) => {
message.msg = message.msg.replace(new RegExp(`(@${mention.username})`, 'gm'), (match) => {
const token = `<i class=notranslate>{${count++}}</i>`;
message.tokens?.push({
token,
text: match,
});
return token;
});
});
}
if (message.channels && message.channels.length > 0) {
message.channels.forEach((channel) => {
message.msg = message.msg.replace(new RegExp(`(#${channel.name})`, 'gm'), (match) => {
const token = `<i class=notranslate>{${count++}}</i>`;
message.tokens?.push({
token,
text: match,
});
return token;
});
});
}
return message;
}
deTokenize(message: IMessage): string {
if (message.tokens && message.tokens?.length > 0) {
for (const { token, text, noHtml } of message.tokens) {
message.msg = message.msg.replace(token, () => noHtml || text);
}
}
return message.msg;
}
/**
* Triggers the translation of the prepared (tokenized) message
* and persists the result
* @public
* @param {object} message
* @param {object} room
* @param {object} targetLanguage
* @returns {object} unmodified message object.
*/
async translateMessage(message: IMessage, { room, targetLanguage }: { room: IRoom; targetLanguage?: string }): Promise<IMessage | null> {
let targetLanguages: string[];
if (targetLanguage) {
targetLanguages = [targetLanguage];
} else {
targetLanguages = (await Subscriptions.getAutoTranslateLanguagesByRoomAndNotUser(room._id, message.u?._id)).filter(isTruthy);
}
if (message.msg) {
setImmediate(async () => {
let targetMessage = Object.assign({}, message);
targetMessage.html = escapeHTML(String(targetMessage.msg));
targetMessage = this.tokenize(targetMessage);
const translations = await this._translateMessage(targetMessage, targetLanguages);
if (!_.isEmpty(translations)) {
await Messages.addTranslations(message._id, translations, TranslationProviderRegistry[Provider] || '');
this.notifyTranslatedMessage(message._id);
}
});
}
if (message.attachments && message.attachments.length > 0) {
setImmediate(async () => {
for (const [index, attachment] of message.attachments?.entries() ?? []) {
if (attachment.description || attachment.text) {
// Removes the initial link `[ ](quoterl)` from quote message before translation
const translatedText = attachment?.text?.replace(/\[(.*?)\]\(.*?\)/g, '$1') || attachment?.text;
const attachmentMessage = { ...attachment, text: translatedText };
const translations = await this._translateAttachmentDescriptions(attachmentMessage, targetLanguages);
if (!_.isEmpty(translations)) {
await Messages.addAttachmentTranslations(message._id, String(index), translations);
this.notifyTranslatedMessage(message._id);
}
}
}
});
}
return Messages.findOneById(message._id);
}
private notifyTranslatedMessage(messageId: string): void {
void notifyOnMessageChange({
id: messageId,
});
}
/**
* Returns metadata information about the service provider which is used by
* the generic implementation
* @abstract
* @protected
* @returns { name, displayName, settings }
};
*/
abstract _getProviderMetadata(): IProviderMetadata;
/**
* Provides the possible languages _from_ which a message can be translated into a target language
* @abstract
* @protected
* @param {string} target - the language into which shall be translated
* @returns [{ language, name }]
*/
abstract getSupportedLanguages(target: string): Promise<ISupportedLanguage[]>;
/**
* Performs the actual translation of a message,
* usually by sending a REST API call to the service provider.
* @abstract
* @protected
* @param {object} message
* @param {object} targetLanguages
* @return {object}
*/
abstract _translateMessage(message: IMessage, targetLanguages: string[]): Promise<ITranslationResult>;
/**
* Performs the actual translation of an attachment (precisely its description),
* usually by sending a REST API call to the service provider.
* @abstract
* @param {object} attachment
* @param {object} targetLanguages
* @returns {object} translated messages for each target language
*/
abstract _translateAttachmentDescriptions(attachment: MessageAttachment, targetLanguages: string[]): Promise<ITranslationResult>;
}
Meteor.startup(() => {
/** Register the active service provider on the 'AfterSaveMessage' callback.
* So the registered provider will be invoked when a message is saved.
* All the other inactive service provider must be deactivated.
*/
settings.watch<string>('AutoTranslate_ServiceProvider', (providerName) => {
TranslationProviderRegistry.setCurrentProvider(providerName);
});
// Get Auto Translate Active flag
settings.watch<boolean>('AutoTranslate_Enabled', (value) => {
TranslationProviderRegistry.setEnable(value);
});
});

View File

@@ -0,0 +1,220 @@
/**
* @author Vigneshwaran Odayappan <vickyokrm@gmail.com>
*/
import type { IMessage, MessageAttachment, IProviderMetadata, ITranslationResult, ISupportedLanguage } from '@rocket.chat/core-typings';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import _ from 'underscore';
import { TranslationProviderRegistry, AutoTranslate } from './autotranslate';
import { settings } from '../../settings';
import { i18n } from '../i18n';
import { SystemLogger } from '../logger/system';
const proApiEndpoint = 'https://api.deepl.com/v2/translate';
const freeApiEndpoint = 'https://api-free.deepl.com/v2/translate';
interface IDeepLTranslation {
detected_source_language: string;
text: string;
}
/**
* DeepL translation service provider class representation.
* Encapsulates the service provider settings and information.
* Provides languages supported by the service provider.
* Resolves API call to service provider to resolve the translation request.
* @class
* @augments AutoTranslate
*/
class DeeplAutoTranslate extends AutoTranslate {
apiKey: string;
apiEndPointUrl: string;
private supportedLanguageEndpointUrl: string;
/**
* setup api reference to deepl translate to be used as message translation provider.
* @constructor
*/
constructor() {
super();
this.name = 'deepl-translate';
this.apiEndPointUrl = proApiEndpoint;
// Get the service provide API key.
settings.watch<string>('AutoTranslate_DeepLAPIKey', (value) => {
this.apiKey = value;
// if the api key ends with `:fx` it is a free api key
if (/:fx$/.test(value)) {
this.apiEndPointUrl = freeApiEndpoint;
this.supportedLanguageEndpointUrl = `https://api-free.deepl.com/v2/languages`;
return;
}
this.apiEndPointUrl = proApiEndpoint;
this.supportedLanguageEndpointUrl = `https://api.deepl.com/v2/languages`;
});
}
/**
* Returns metadata information about the service provide
* @private implements super abstract method.
* @return {object}
*/
_getProviderMetadata(): IProviderMetadata {
return {
name: this.name,
displayName: i18n.t('AutoTranslate_DeepL'),
settings: this._getSettings(),
};
}
/**
* Returns necessary settings information about the translation service provider.
* @private implements super abstract method.
* @return {object}
*/
_getSettings(): IProviderMetadata['settings'] {
return {
apiKey: this.apiKey,
apiEndPointUrl: this.apiEndPointUrl,
};
}
/**
* Returns supported languages for translation by the active service provider.
* Deepl does not provide an endpoint yet to retrieve the supported languages.
* So each supported languages are explicitly maintained.
* @private implements super abstract method.
* @param {string} target
* @returns {object} code : value pair
*/
async getSupportedLanguages(target: string): Promise<ISupportedLanguage[]> {
if (!this.apiKey) {
return [];
}
if (this.supportedLanguages[target]) {
return this.supportedLanguages[target];
}
let result: (ISupportedLanguage & { supports_formality?: boolean })[] = [];
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch(this.supportedLanguageEndpointUrl, {
ignoreSsrfValidation: true,
params: { type: 'target' },
headers: {
Authorization: `DeepL-Auth-Key ${this.apiKey}`,
},
});
if (!request.ok) {
throw new Error('Failed to fetch supported languages');
}
result = (await request.json()) as typeof result;
if (this.supportedLanguages[target]) {
return this.supportedLanguages[target];
}
this.supportedLanguages[target || 'en'] = result.map(({ language, ...other }) => ({
...other,
language: new Intl.Locale(language).toString(),
}));
return this.supportedLanguages[target || 'en'];
}
/**
* Send Request REST API call to the service provider.
* Returns translated message for each target language in target languages.
* @private
* @param {object} message
* @param {object} targetLanguages
* @returns {object} translations: Translated messages for each language
*/
async _translateMessage(message: IMessage, targetLanguages: string[]): Promise<ITranslationResult> {
const translations: { [k: string]: string } = {};
const msgs = message.msg.split('\n');
const supportedLanguages = await this.getSupportedLanguages('en');
for (let language of targetLanguages) {
if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) {
language = language.substr(0, 2);
}
try {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const result = await fetch(this.apiEndPointUrl, {
ignoreSsrfValidation: true,
params: { target_lang: language, text: msgs },
headers: {
Authorization: `DeepL-Auth-Key ${this.apiKey}`,
},
method: 'POST',
});
if (!result.ok) {
throw new Error(result.statusText);
}
const body = await result.json();
if (result.status === 200 && body.translations && Array.isArray(body.translations) && body.translations.length > 0) {
// store translation only when the source and target language are different.
// multiple lines might contain different languages => Mix the text between source and detected target if neccessary
const translatedText = body.translations
.map((translation: IDeepLTranslation, index: number) =>
translation.detected_source_language !== language ? translation.text : msgs[index],
)
.join('\n');
translations[language] = this.deTokenize(Object.assign({}, message, { msg: translatedText }));
}
} catch (err) {
SystemLogger.error({ msg: 'Error translating message', err });
}
}
return translations;
}
/**
* Returns translated message attachment description in target languages.
* @private
* @param {object} attachment
* @param {object} targetLanguages
* @returns {object} translated messages for each target language
*/
async _translateAttachmentDescriptions(attachment: MessageAttachment, targetLanguages: string[]): Promise<ITranslationResult> {
const translations: { [k: string]: string } = {};
const supportedLanguages = await this.getSupportedLanguages('en');
for (let language of targetLanguages) {
if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) {
language = language.substr(0, 2);
}
try {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const result = await fetch(this.apiEndPointUrl, {
ignoreSsrfValidation: true,
params: {
auth_key: this.apiKey,
target_lang: language,
text: attachment.description || attachment.text || '',
},
});
if (!result.ok) {
throw new Error(result.statusText);
}
const body = await result.json();
if (result.status === 200 && body.translations && Array.isArray(body.translations) && body.translations.length > 0) {
if (body.translations.map((translation: IDeepLTranslation) => translation.detected_source_language).join() !== language) {
translations[language] = body.translations.map((translation: IDeepLTranslation) => translation.text);
}
}
} catch (err) {
SystemLogger.error({ msg: 'Error translating message attachment', err });
}
}
return translations;
}
}
// Register DeepL translation provider to the registry.
TranslationProviderRegistry.registerProvider(new DeeplAutoTranslate());

View File

@@ -0,0 +1,19 @@
import { Meteor } from 'meteor/meteor';
import { settings } from '../../../settings';
import { hasPermissionAsync } from '../../authorization/hasPermission';
import { TranslationProviderRegistry } from '../index';
export const getSupportedLanguages = async (userId: string, targetLanguage: string) => {
if (!settings.get('AutoTranslate_Enabled')) {
throw new Meteor.Error('error-autotranslate-disabled', 'Auto-Translate is disabled');
}
if (!(await hasPermissionAsync(userId, 'auto-translate'))) {
throw new Meteor.Error('error-action-not-allowed', 'Auto-Translate is not allowed', {
method: 'autoTranslate.getSupportedLanguages',
});
}
return TranslationProviderRegistry.getSupportedLanguages(targetLanguage);
};

View File

@@ -0,0 +1,78 @@
import { Subscriptions, Rooms } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../authorization/hasPermission';
import { notifyOnSubscriptionChangedById } from '../../notifyListener';
export const saveAutoTranslateSettings = async (
userId: string,
rid: string,
field: string,
value: string,
options: { defaultLanguage: string },
) => {
if (!(await hasPermissionAsync(userId, 'auto-translate'))) {
throw new Meteor.Error('error-action-not-allowed', 'Auto-Translate is not allowed', {
method: 'autoTranslate.saveSettings',
});
}
check(rid, String);
check(field, String);
check(value, String);
if (['autoTranslate', 'autoTranslateLanguage'].indexOf(field) === -1) {
throw new Meteor.Error('error-invalid-settings', 'Invalid settings field', {
method: 'saveAutoTranslateSettings',
});
}
const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userId);
if (!subscription) {
throw new Meteor.Error('error-invalid-subscription', 'Invalid subscription', {
method: 'saveAutoTranslateSettings',
});
}
let shouldNotifySubscriptionChanged = false;
switch (field) {
case 'autoTranslate':
const room = await Rooms.findE2ERoomById(rid, { projection: { _id: 1 } });
if (room && value === '1') {
throw new Meteor.Error('error-e2e-enabled', 'Enabling auto-translation in E2E encrypted rooms is not allowed', {
method: 'saveAutoTranslateSettings',
});
}
const updateAutoTranslateResponse = await Subscriptions.updateAutoTranslateById(subscription._id, value === '1');
if (updateAutoTranslateResponse.modifiedCount) {
shouldNotifySubscriptionChanged = true;
}
if (!subscription.autoTranslateLanguage && options.defaultLanguage) {
const updateAutoTranslateLanguageResponse = await Subscriptions.updateAutoTranslateLanguageById(
subscription._id,
options.defaultLanguage,
);
if (updateAutoTranslateLanguageResponse.modifiedCount) {
shouldNotifySubscriptionChanged = true;
}
}
break;
case 'autoTranslateLanguage':
const updateAutoTranslateLanguage = await Subscriptions.updateAutoTranslateLanguageById(subscription._id, value);
if (updateAutoTranslateLanguage.modifiedCount) {
shouldNotifySubscriptionChanged = true;
}
break;
}
if (shouldNotifySubscriptionChanged) {
void notifyOnSubscriptionChangedById(subscription._id);
}
return true;
};

View File

@@ -0,0 +1,26 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { Rooms } from '@rocket.chat/models';
import { TranslationProviderRegistry } from '../index';
export const translateMessage = async (targetLanguage?: string, message?: IMessage) => {
if (!TranslationProviderRegistry.enabled) {
return;
}
if (!message?.rid) {
return;
}
const room = await Rooms.findOneById(message?.rid);
let translatedMessage;
if (message && room) {
translatedMessage = await TranslationProviderRegistry.translateMessage(message, room, targetLanguage);
}
if (!translatedMessage) {
return;
}
return translatedMessage;
};

View File

@@ -0,0 +1,223 @@
/**
* @author Vigneshwaran Odayappan <vickyokrm@gmail.com>
*/
import type { IMessage, IProviderMetadata, ISupportedLanguage, ITranslationResult, MessageAttachment } from '@rocket.chat/core-typings';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import _ from 'underscore';
import { AutoTranslate, TranslationProviderRegistry } from './autotranslate';
import { settings } from '../../settings';
import { i18n } from '../i18n';
import { SystemLogger } from '../logger/system';
interface IGoogleTranslation {
translatedText: string;
}
/**
* Represents google translate class
* @class
* @augments AutoTranslate
*/
class GoogleAutoTranslate extends AutoTranslate {
apiKey: string;
apiEndPointUrl: string;
/**
* setup api reference to Google translate to be used as message translation provider.
* @constructor
*/
constructor() {
super();
this.name = 'google-translate';
this.apiEndPointUrl = 'https://translation.googleapis.com/language/translate/v2';
// Get the service provide API key.
settings.watch<string>('AutoTranslate_GoogleAPIKey', (value) => {
this.apiKey = value;
});
}
/**
* Returns metadata information about the service provider
* @private implements super abstract method.
* @returns {object}
*/
_getProviderMetadata(): IProviderMetadata {
return {
name: this.name,
displayName: i18n.t('AutoTranslate_Google'),
settings: this._getSettings(),
};
}
/**
* Returns necessary settings information about the translation service provider.
* @private implements super abstract method.
* @returns {object}
*/
_getSettings(): IProviderMetadata['settings'] {
return {
apiKey: this.apiKey,
apiEndPointUrl: this.apiEndPointUrl,
};
}
/**
* Returns supported languages for translation by the active service provider.
* Google Translate api provides the list of supported languages.
* @private implements super abstract method.
* @param {string} target : user language setting or 'en'
* @returns {object} code : value pair
*/
async getSupportedLanguages(target: string): Promise<ISupportedLanguage[]> {
if (!this.apiKey) {
return [];
}
if (this.supportedLanguages[target]) {
return this.supportedLanguages[target];
}
let result: { data?: { languages: ISupportedLanguage[] } } = {};
const params = {
key: this.apiKey,
...(target && { target }),
};
try {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch(`https://translation.googleapis.com/language/translate/v2/languages`, {
ignoreSsrfValidation: true,
params,
});
if (!request.ok && request.status === 400 && request.statusText === 'INVALID_ARGUMENT') {
throw new Error('Failed to fetch supported languages');
}
result = (await request.json()) as typeof result;
} catch (e: any) {
// Fallback: Get the English names of the target languages
if (e.message === 'Failed to fetch supported languages') {
params.target = 'en';
target = 'en';
if (!this.supportedLanguages[target]) {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch(`https://translation.googleapis.com/language/translate/v2/languages`, {
ignoreSsrfValidation: true,
params,
});
result = (await request.json()) as typeof result;
}
}
}
if (this.supportedLanguages[target]) {
return this.supportedLanguages[target];
}
this.supportedLanguages[target || 'en'] = result?.data?.languages || [];
return this.supportedLanguages[target || 'en'];
}
/**
* Send Request REST API call to the service provider.
* Returns translated message for each target language in target languages.
* @private
* @param {object} message
* @param {object} targetLanguages
* @returns {object} translations: Translated messages for each language
*/
async _translateMessage(message: IMessage, targetLanguages: string[]): Promise<ITranslationResult> {
const translations: { [k: string]: string } = {};
const supportedLanguages = await this.getSupportedLanguages('en');
for (let language of targetLanguages) {
if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) {
language = language.slice(0, 2);
}
try {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const result = await fetch(this.apiEndPointUrl, {
ignoreSsrfValidation: true,
params: {
key: this.apiKey,
target: language,
format: 'text',
q: message.msg.split('\n'),
},
});
if (!result.ok) {
throw new Error(result.statusText);
}
const body = await result.json();
if (
result.status === 200 &&
body.data?.translations &&
Array.isArray(body.data.translations) &&
body.data.translations.length > 0
) {
const txt = body.data.translations.map((translation: IGoogleTranslation) => translation.translatedText).join('\n');
translations[language] = this.deTokenize(Object.assign({}, message, { msg: txt }));
}
} catch (err) {
SystemLogger.error({ msg: 'Error translating message', err });
}
}
return translations;
}
/**
* Returns translated message attachment description in target languages.
* @private
* @param {object} attachment
* @param {object} targetLanguages
* @returns {object} translated attachment descriptions for each target language
*/
async _translateAttachmentDescriptions(attachment: MessageAttachment, targetLanguages: string[]): Promise<ITranslationResult> {
const translations: { [k: string]: string } = {};
const supportedLanguages = await this.getSupportedLanguages('en');
for (let language of targetLanguages) {
if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) {
language = language.slice(0, 2);
}
try {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const result = await fetch(this.apiEndPointUrl, {
ignoreSsrfValidation: true,
params: {
key: this.apiKey,
target: language,
format: 'text',
q: attachment.description || attachment.text || '',
},
});
if (!result.ok) {
throw new Error(result.statusText);
}
const body = await result.json();
if (
result.status === 200 &&
body.data?.translations &&
Array.isArray(body.data.translations) &&
body.data.translations.length > 0
) {
translations[language] = body.data.translations.map((translation: IGoogleTranslation) => translation.translatedText).join('\n');
}
} catch (err) {
SystemLogger.error({ msg: 'Error translating message', err });
}
}
return translations;
}
}
// Register Google translation provider.
TranslationProviderRegistry.registerProvider(new GoogleAutoTranslate());

View File

@@ -0,0 +1,13 @@
/**
* This file contains the exported members of the package shall be re-used.
* @module AutoTranslate, TranslationProviderRegistry
*/
import { TranslationProviderRegistry } from './autotranslate';
import './permissions';
import './googleTranslate';
import './deeplTranslate';
import './libreTranslate';
import './msTranslate';
export { TranslationProviderRegistry };

View File

@@ -0,0 +1,179 @@
import type { IMessage, MessageAttachment, IProviderMetadata, ITranslationResult, ISupportedLanguage } from '@rocket.chat/core-typings';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { TranslationProviderRegistry, AutoTranslate } from './autotranslate';
import { libreLogger } from './logger';
import { settings } from '../../settings';
import { i18n } from '../i18n';
interface ILibreTranslateLanguage {
code: string;
name: string;
targets?: string[];
}
const REQUEST_TIMEOUT = 10000;
const toLanguageTag = (code: string): string => {
try {
return new Intl.Locale(code).toString();
} catch (err) {
libreLogger.error({ msg: 'Invalid language code returned by LibreTranslate', code, err });
return code;
}
};
/**
* LibreTranslate translation service provider class representation.
* @class
* @augments AutoTranslate
*/
class LibreTranslateAutoTranslate extends AutoTranslate {
apiKey: string;
apiEndPointUrl: string;
constructor() {
super();
this.name = 'libre-translate';
this.apiEndPointUrl = '';
this.apiKey = '';
settings.watch<string>('AutoTranslate_LibreTranslateAPIURL', (value) => {
this.apiEndPointUrl = (value || '').replace(/\/$/, '');
});
settings.watch<string>('AutoTranslate_LibreTranslateAPIKey', (value) => {
this.apiKey = value;
});
}
_getProviderMetadata(): IProviderMetadata {
return {
name: this.name,
displayName: i18n.t('AutoTranslate_LibreTranslate'),
settings: this._getSettings(),
};
}
_getSettings(): IProviderMetadata['settings'] {
return {
apiKey: this.apiKey,
apiEndPointUrl: this.apiEndPointUrl,
};
}
async getSupportedLanguages(target: string): Promise<ISupportedLanguage[]> {
if (!this.apiEndPointUrl) {
return [];
}
if (this.supportedLanguages[target]) {
return this.supportedLanguages[target];
}
try {
// SECURITY: the URL comes from a setting only an admin can change, so disabling SSRF validation is safe here.
const request = await fetch(`${this.apiEndPointUrl}/languages`, {
ignoreSsrfValidation: true,
timeout: REQUEST_TIMEOUT,
});
if (!request.ok) {
throw new Error('Failed to fetch supported languages');
}
const result = (await request.json()) as ILibreTranslateLanguage[];
this.supportedLanguages[target || 'en'] = result.map(({ code, name }) => ({
language: toLanguageTag(code),
name,
}));
return this.supportedLanguages[target || 'en'];
} catch (err) {
libreLogger.error({ msg: 'Error fetching supported languages', err });
return [];
}
}
private resolveTargetLanguage(language: string, supportedLanguages: ISupportedLanguage[]): string {
if (language.includes('-') && !supportedLanguages.find((supported) => supported.language === language)) {
return language.slice(0, 2);
}
return language;
}
// `q` is sent as an array of lines; LibreTranslate returns `translatedText` as an array (one entry per line).
private async _query(lines: string[], targetLanguage: string): Promise<string | null> {
const body: Record<string, unknown> = {
q: lines,
source: 'auto',
target: targetLanguage,
format: 'text',
...(this.apiKey && { api_key: this.apiKey }),
};
// SECURITY: the URL comes from a setting only an admin can change, so disabling SSRF validation is safe here.
const result = await fetch(`${this.apiEndPointUrl}/translate`, {
method: 'POST',
ignoreSsrfValidation: true,
timeout: REQUEST_TIMEOUT,
headers: { 'Content-Type': 'application/json' },
body,
});
if (!result.ok) {
throw new Error(result.statusText);
}
const json = (await result.json()) as { translatedText?: string | string[] };
if (json.translatedText === undefined) {
return null;
}
return Array.isArray(json.translatedText) ? json.translatedText.join('\n') : json.translatedText;
}
async _translateMessage(message: IMessage, targetLanguages: string[]): Promise<ITranslationResult> {
const translations: { [k: string]: string } = {};
if (!this.apiEndPointUrl) {
return translations;
}
const msgs = message.msg.split('\n');
const supportedLanguages = await this.getSupportedLanguages('en');
for (const targetLanguage of targetLanguages) {
const language = this.resolveTargetLanguage(targetLanguage, supportedLanguages);
try {
const translatedText = await this._query(msgs, language);
if (translatedText !== null) {
translations[language] = this.deTokenize(Object.assign({}, message, { msg: translatedText }));
}
} catch (err) {
libreLogger.error({ msg: 'Error translating message', err });
}
}
return translations;
}
async _translateAttachmentDescriptions(attachment: MessageAttachment, targetLanguages: string[]): Promise<ITranslationResult> {
const translations: { [k: string]: string } = {};
if (!this.apiEndPointUrl) {
return translations;
}
const supportedLanguages = await this.getSupportedLanguages('en');
for (const targetLanguage of targetLanguages) {
const language = this.resolveTargetLanguage(targetLanguage, supportedLanguages);
try {
const translatedText = await this._query([attachment.description || attachment.text || ''], language);
if (translatedText !== null) {
translations[language] = translatedText;
}
} catch (err) {
libreLogger.error({ msg: 'Error translating message attachment', err });
}
}
return translations;
}
}
TranslationProviderRegistry.registerProvider(new LibreTranslateAutoTranslate());

View File

@@ -0,0 +1,7 @@
import { Logger } from '@rocket.chat/logger';
const logger = new Logger('AutoTranslate');
export const msLogger = logger.section('Microsoft');
export const libreLogger = logger.section('LibreTranslate');

View File

@@ -0,0 +1,208 @@
/**
* @author Vigneshwaran Odayappan <vickyokrm@gmail.com>
*/
import type { IMessage, IProviderMetadata, ISupportedLanguage, ITranslationResult, MessageAttachment } from '@rocket.chat/core-typings';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import _ from 'underscore';
import { TranslationProviderRegistry, AutoTranslate } from './autotranslate';
import { msLogger } from './logger';
import { settings } from '../../settings';
import { i18n } from '../i18n';
/**
* Microsoft translation service provider class representation.
* Encapsulates the service provider settings and information.
* Provides languages supported by the service provider.
* Resolves API call to service provider to resolve the translation request.
* @class
* @augments AutoTranslate
*/
class MsAutoTranslate extends AutoTranslate {
apiKey: string;
apiEndPointUrl: string;
apiDetectText: string;
apiGetLanguages: string;
breakSentence: string;
/**
* setup api reference to Microsoft translate to be used as message translation provider.
* @constructor
*/
constructor() {
super();
this.name = 'microsoft-translate';
this.apiEndPointUrl = 'https://api.cognitive.microsofttranslator.com/translate?api-version=3.0';
this.apiDetectText = 'https://api.cognitive.microsofttranslator.com/detect?api-version=3.0';
this.apiGetLanguages = 'https://api.cognitive.microsofttranslator.com/languages?api-version=3.0';
this.breakSentence = 'https://api.cognitive.microsofttranslator.com/breaksentence?api-version=3.0';
// Get the service provide API key.
settings.watch<string>('AutoTranslate_MicrosoftAPIKey', (value) => {
this.apiKey = value;
});
}
/**
* Returns metadata information about the service provide
* @private implements super abstract method.
* @return {object}
*/
_getProviderMetadata(): IProviderMetadata {
return {
name: this.name,
displayName: i18n.t('AutoTranslate_Microsoft'),
settings: this._getSettings(),
};
}
/**
* Returns necessary settings information about the translation service provider.
* @private implements super abstract method.
* @return {object}
*/
_getSettings(): IProviderMetadata['settings'] {
return {
apiKey: this.apiKey,
apiEndPointUrl: this.apiEndPointUrl,
};
}
/**
* Returns supported languages for translation by the active service provider.
* Microsoft does not provide an endpoint yet to retrieve the supported languages.
* So each supported languages are explicitly maintained.
* @private implements super abstract method.
* @param {string} target
* @returns {object} code : value pair
*/
async getSupportedLanguages(target: string): Promise<ISupportedLanguage[]> {
if (!this.apiKey) {
return [];
}
if (this.supportedLanguages[target]) {
return this.supportedLanguages[target];
}
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch(this.apiGetLanguages, {
ignoreSsrfValidation: true,
});
if (!request.ok) {
throw new Error(request.statusText);
}
const languages = await request.json();
this.supportedLanguages[target] = Object.keys(languages.translation).map((language) => ({
language,
name: languages.translation[language].name,
}));
return this.supportedLanguages[target || 'en'];
}
/**
* Re-use method for REST API consumption of MS translate.
* @private
* @param {object} message
* @param {object} targetLanguages
* @throws Communication Errors
* @returns {object} translations: Translated messages for each language
*/
async _translate(
data: {
Text: string;
}[],
targetLanguages: string[],
): Promise<ITranslationResult> {
let translations: { [k: string]: string } = {};
const supportedLanguages = await this.getSupportedLanguages('en');
targetLanguages = targetLanguages.map((language) => {
if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) {
language = language.substr(0, 2);
}
return language;
});
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch(this.apiEndPointUrl, {
ignoreSsrfValidation: true,
method: 'POST',
headers: {
'Ocp-Apim-Subscription-Key': this.apiKey,
'Content-Type': 'application/json; charset=UTF-8',
},
body: data,
params: {
to: targetLanguages,
},
});
if (!request.ok) {
throw new Error(request.statusText);
}
const result = await request.json();
if (request.status === 200 && result.length > 0) {
// store translation only when the source and target language are different.
translations = Object.assign(
{},
...targetLanguages.map((language) => ({
[language]: result
.map(
(line: { translations: { to: string; text: string }[] }) =>
line.translations.find((translation) => translation.to === language)?.text,
)
.join('\n'),
})),
);
}
return translations;
}
/**
* Returns translated message for each target language.
* @private
* @param {object} message
* @param {object} targetLanguages
* @returns {object} translations: Translated messages for each language
*/
async _translateMessage(message: IMessage, targetLanguages: string[]): Promise<ITranslationResult> {
// There are multi-sentence-messages where multiple sentences come from different languages
// This is a problem for translation services since the language detection fails.
// Thus, we'll split the message in sentences, get them translated, and join them again after translation
const msgs = message.msg.split('\n').map((msg) => ({ Text: msg }));
try {
return this._translate(msgs, targetLanguages);
} catch (e) {
msLogger.error({ err: e, msg: 'Error translating message' });
}
return {};
}
/**
* Returns translated message attachment description in target languages.
* @private
* @param {object} attachment
* @param {object} targetLanguages
* @returns {object} translated messages for each target language
*/
async _translateAttachmentDescriptions(attachment: MessageAttachment, targetLanguages: string[]): Promise<ITranslationResult> {
try {
return this._translate(
[
{
Text: attachment.description || attachment.text || '',
},
],
targetLanguages,
);
} catch (e) {
msLogger.error({ err: e, msg: 'Error translating message attachment' });
}
return {};
}
}
// Register Microsoft translation provider to the registry.
TranslationProviderRegistry.registerProvider(new MsAutoTranslate());

View File

@@ -0,0 +1,8 @@
import { Permissions } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
Meteor.startup(async () => {
if (!(await Permissions.findOne({ _id: 'auto-translate' }))) {
await Permissions.create('auto-translate', ['admin']);
}
});

View File

@@ -0,0 +1,60 @@
import { isBannedSubscription } from '@rocket.chat/core-typings';
import { Rooms, Subscriptions, Users, Roles } from '@rocket.chat/models';
import { canAccessRoomAsync } from './authorization';
import { hasPermissionAsync } from './authorization/hasPermission';
import { hasRoleAsync } from './authorization/hasRole';
import { banUserFromRoom } from './rooms/banUserFromRoom';
import { roomCoordinator } from './rooms/roomCoordinator';
import { RoomMemberActions } from '../../definition/IRoomTypeConfig';
export const banUserFromRoomMethod = async (fromId: string, data: { rid: string; username: string }): Promise<boolean> => {
if (!(await hasPermissionAsync(fromId, 'ban-user', data.rid))) {
throw new Error('Not allowed');
}
const room = await Rooms.findOneById(data.rid);
if (!room || !(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.BAN, fromId))) {
throw new Error('Not allowed');
}
const fromUser = await Users.findOneById(fromId);
if (!fromUser) {
throw new Error('Invalid user');
}
if (!(await canAccessRoomAsync(room, fromUser))) {
throw new Error('The required "roomId" or "roomName" param provided does not match any group');
}
const bannedUser = await Users.findOneByUsernameIgnoringCase(data.username);
if (!bannedUser) {
throw new Error('User not found');
}
const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, bannedUser._id, {
projection: { _id: 1, status: 1 },
});
if (!subscription) {
throw new Error('User is not in this room');
}
// Cannot ban a user who is already banned
if (isBannedSubscription(subscription)) {
throw new Error('User is already banned from this room');
}
// Cannot ban the last owner
if (await hasRoleAsync(bannedUser._id, 'owner', room._id)) {
const numOwners = await Roles.countUsersInRole('owner', room._id);
if (numOwners === 1) {
throw new Error('You are the last owner. Please set new owner before banning the user.');
}
}
await banUserFromRoom(data.rid, bannedUser, fromUser);
return true;
};

View File

@@ -0,0 +1,54 @@
# Rocket.chat bot helpers
This package provides some extra shortcut Meteor methods intended to help Hubot scripts.
## Approach
This was an experiment in how to extend Hubot and Rocket.chat integration.
Keeping controller logic out of Hubot scripts made sense to me, but its not necessarily the best approach.
For bots to use these methods they obviously need this package added to Rocket.chat as well as the [rocketchat adapter](https://github.com/RocketChat/hubot-rocketchat).
## Usage
In Hubot scripts, call bot helper methods using Meteor.call with the first argument as the helper type, then the method name as second argument.
At present, there's only one type of helper (`botRequest`) for simple getters, more utility methods may be added in future.
e.g. Below uses `onlineNames` method, which returns array of names of online users (not including bots).
Listens for *"who is online"* and replies with something like *"Robert, Desmond and Billie are currently online"*.
```
# Use Bot Helpers class to check who's online
robot.hear /who is online/i, (res) ->
promise = robot.adapter.callMethod('botRequest', 'onlineNames')
promise.then (result) ->
if result.length > 0
names = result.join(', ').replace(/,(?=[^,]*$)/, ' and ') # convert last comma to 'and'
res.send "#{ names } #{ if result.length == 1 then 'is' else 'are' } currently online"
else
res.send "Nobody is currently online... \*cricket sound\*"
return
, (error) ->
res.send "Uh, sorry I don't know, something's not working"
return
```
Method names that include fields like `allUsernames` return an 1D array of just that property.
First two methods return 2D array with properties defined in a defaults object (currently id, name, username, status, emails).
- `allUsers`
- `onlineUsers`
- `allUsernames`
- `onlineUsernames`
- `allNames`
- `onlineNames`
- `allIDs`
- `onlineIDs`
## Tests
None yet, PR adding tests would be much apreciated.

View File

@@ -0,0 +1,214 @@
import type { IUser } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Rooms, Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import type { Filter, FindCursor } from 'mongodb';
import { addUserToRole } from '../../meteor-methods/auth/addUserToRole';
import { removeUserFromRole } from '../../meteor-methods/auth/removeUserFromRole';
import { addUsersToRoomMethod } from '../../meteor-methods/rooms/addUsersToRoom';
import { removeUserFromRoomMethod } from '../../meteor-methods/rooms/removeUserFromRoom';
import { settings } from '../../settings';
import { hasRoleAsync } from '../authorization/hasRole';
/**
* BotHelpers helps bots
* "private" properties use meteor collection cursors, so they stay reactive
* "public" properties use getters to fetch and filter collections as array
*/
class BotHelpers {
private queries: {
online: Filter<IUser>;
users: Filter<IUser>;
};
private userFields: Record<string, 1>;
private _allUsers: FindCursor<IUser>;
private _onlineUsers: FindCursor<IUser>;
constructor() {
this.queries = {
online: { status: { $ne: UserStatus.OFFLINE } },
users: { roles: { $not: { $all: ['bot'] } } },
};
}
// setup collection cursors with array of fields from setting
setupCursors(fieldsSetting: string | string[]) {
this.userFields = {};
if (typeof fieldsSetting === 'string') {
fieldsSetting = fieldsSetting.split(',');
}
fieldsSetting.forEach((n) => {
this.userFields[n.trim()] = 1;
});
this._allUsers = Users.find(this.queries.users, { projection: this.userFields });
this._onlineUsers = Users.find({ $and: [this.queries.users, this.queries.online] }, { projection: this.userFields });
}
// request methods or props as arguments to Meteor.call
async request(prop: keyof this, ...params: unknown[]): Promise<any> {
const p = this[prop];
if (typeof p === 'undefined') {
return null;
}
if (typeof p === 'function') {
return p(...params);
}
return p;
}
async addUserToRole(userName: string, roleId: string, userId: string): Promise<void> {
await addUserToRole(userId, roleId, userName);
}
async removeUserFromRole(userName: string, roleId: string, userId: string): Promise<void> {
await removeUserFromRole(userId, roleId, userName);
}
async addUserToRoom(userName: string, room: string): Promise<void> {
const foundRoom = await Rooms.findOneByIdOrName(room);
if (!foundRoom) {
throw new Meteor.Error('invalid-channel');
}
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'addUserToRoom' });
}
await addUsersToRoomMethod(userId, {
rid: foundRoom._id,
users: [userName],
});
}
async removeUserFromRoom(userName: string, room: string) {
const foundRoom = await Rooms.findOneByIdOrName(room);
if (!foundRoom) {
throw new Meteor.Error('invalid-channel');
}
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user');
}
await removeUserFromRoomMethod(userId, { rid: foundRoom._id, username: userName });
}
// generic error whenever property access insufficient to fill request
requestError() {
throw new Meteor.Error('error-not-allowed', 'Bot request not allowed', {
method: 'botRequest',
action: 'bot_request',
});
}
// "public" properties accessed by getters
// allUsers / onlineUsers return whichever properties are enabled by settings
get allUsers() {
if (!Object.keys(this.userFields).length) {
this.requestError();
return false;
}
return this._allUsers.toArray();
}
get onlineUsers() {
if (!Object.keys(this.userFields).length) {
this.requestError();
return false;
}
return this._onlineUsers.toArray();
}
get allUsernames() {
return (async () => {
if (!this.userFields.hasOwnProperty('username')) {
this.requestError();
return false;
}
return (await this._allUsers.toArray()).map((user) => user.username);
})();
}
get onlineUsernames() {
return (async () => {
if (!this.userFields.hasOwnProperty('username')) {
this.requestError();
return false;
}
return (await this._onlineUsers.toArray()).map((user) => user.username);
})();
}
get allNames() {
return (async () => {
if (!this.userFields.hasOwnProperty('name')) {
this.requestError();
return false;
}
return (await this._allUsers.toArray()).map((user) => user.name);
})();
}
get onlineNames() {
return (async () => {
if (!this.userFields.hasOwnProperty('name')) {
this.requestError();
return false;
}
return (await this._onlineUsers.toArray()).map((user) => user.name);
})();
}
get allIDs() {
return (async () => {
if (!this.userFields.hasOwnProperty('_id') || !this.userFields.hasOwnProperty('username')) {
this.requestError();
return false;
}
return (await this._allUsers.toArray()).map((user) => ({ id: user._id, name: user.username }));
})();
}
get onlineIDs() {
return (async () => {
if (!this.userFields.hasOwnProperty('_id') || !this.userFields.hasOwnProperty('username')) {
this.requestError();
return false;
}
return (await this._onlineUsers.toArray()).map((user) => ({ id: user._id, name: user.username }));
})();
}
}
// add class to meteor methods
const botHelpers = new BotHelpers();
// init cursors with fields setting and update on setting change
settings.watch<string>('BotHelpers_userFields', (value) => {
botHelpers.setupCursors(value);
});
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
botRequest: (prop: keyof BotHelpers, ...params: unknown[]) => Promise<unknown>;
}
}
Meteor.methods<ServerMethods>({
async botRequest(...args) {
const userID = Meteor.userId();
if (userID && (await hasRoleAsync(userID, 'bot'))) {
return botHelpers.request(...args, userID);
}
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'botRequest' });
},
});

View File

@@ -0,0 +1,43 @@
import Bugsnag from '@bugsnag/js';
import { Logger } from '@rocket.chat/logger';
import { Meteor } from 'meteor/meteor';
import { Info } from '../../app/utils/rocketchat.info';
import { settings } from '../settings';
const logger = new Logger('bugsnag');
const originalMeteorDebug = Meteor._debug;
function _bugsnagDebug(message: any, stack: any, ...args: any): void {
if (stack instanceof Error) {
Bugsnag.notify(stack, (event) => {
event.context = message;
});
} else {
if (typeof stack === 'string') {
message += ` ${stack}`;
}
const error = new Error(message);
error.stack = stack;
Bugsnag.notify(error);
}
return originalMeteorDebug(message, stack, ...args);
}
settings.watch('Bugsnag_api_key', (value) => {
if (!value) {
return;
}
Bugsnag.start({
apiKey: value as string,
appVersion: Info.version,
logger,
metadata: Info,
});
Meteor._debug = _bugsnagDebug;
});

View File

@@ -0,0 +1,71 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import { callbacks } from './callbacks';
import { Callbacks } from './callbacks/callbacksBase';
describe('callbacks legacy', () => {
it("if the callback doesn't return any value should return the original", async () => {
callbacks.add('test', () => undefined, callbacks.priority.LOW, '1');
const result = await callbacks.run('test', true);
expect(result).to.be.true;
callbacks.remove('test', '1');
});
it('should return the value returned by the callback', async () => {
callbacks.add('test', () => false, callbacks.priority.LOW, '1');
const result = await callbacks.run('test', true);
expect(result).to.be.false;
callbacks.remove('test', '1');
});
it('should accumulate the values returned by the callbacks', async () => {
callbacks.add('test', (old: number) => old * 5);
callbacks.add('test', (old: number) => old * 2);
expect(await callbacks.run('test', 3)).to.be.equal(30);
expect(await callbacks.run('test', 2)).to.be.equal(20);
});
});
describe('callbacks', () => {
it("if the callback doesn't return any value should return the original", async () => {
const test = Callbacks.create<(data: boolean) => boolean>('test');
test.add(() => undefined, callbacks.priority.LOW, '1');
const result = await test.run(true);
expect(result).to.be.true;
});
it('should return the value returned by the callback', async () => {
const test = Callbacks.create<(data: boolean) => boolean>('test');
test.add(() => false, callbacks.priority.LOW, '1');
const result = await test.run(true);
expect(result).to.be.false;
});
it('should accumulate the values returned by the callbacks', async () => {
const test = Callbacks.create<(data: number) => number>('test');
test.add((old) => old * 5);
test.add((old) => old * 2);
expect(await test.run(3)).to.be.equal(30);
expect(await test.run(2)).to.be.equal(20);
});
});

View File

@@ -0,0 +1,230 @@
import type { ICreateRoomOptions } from '@rocket.chat/core-services';
import type {
IMessage,
IRoom,
IUser,
ILivechatDepartmentRecord,
ILivechatAgent,
OmnichannelAgentStatus,
ILivechatInquiryRecord,
ILivechatVisitor,
VideoConference,
IOmnichannelRoom,
ILivechatTag,
ILivechatTagRecord,
TransferData,
AtLeast,
UserStatus,
ILivechatDepartment,
MessageMention,
IOmnichannelInquiryExtraData,
} from '@rocket.chat/core-typings';
import type { Updater } from '@rocket.chat/models';
import type { FilterOperators } from 'mongodb';
import type { ILoginAttempt } from './auth/ILoginAttempt';
import { Callbacks } from './callbacks/callbacksBase';
import type { SendMessageOptions } from './messages/sendMessage';
import type { IBusinessHourBehavior } from './omnichannel/business-hour/AbstractBusinessHour';
import type { CloseRoomParams } from './omnichannel/localTypes';
/**
* Callbacks returning void, like event listeners.
*
* TODO: move those to event-based systems
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
interface EventLikeCallbackSignatures {
'afterActivateUser': (user: IUser) => void;
'afterCreateChannel': (owner: IUser, room: IRoom) => void;
'afterCreatePrivateGroup': (owner: IUser, room: IRoom) => void;
'afterDeactivateUser': (user: IUser) => void;
'afterDeleteMessage': (message: IMessage, params: { room: IRoom; user: IUser }) => void;
'workspaceLicenseChanged': (license: string) => void;
'workspaceLicenseRemoved': () => void;
'afterReadMessages': (room: IRoom, params: { uid: IUser['_id']; lastSeen?: Date; tmid?: IMessage['_id'] }) => void;
'beforeReadMessages': (rid: IRoom['_id'], uid: IUser['_id']) => void;
'afterDeleteUser': (user: IUser) => void;
'afterFileUpload': (params: { user: IUser; room: IRoom; message: IMessage }) => void;
'afterRoomNameChange': (params: { room: IRoom; name: string; oldName: string; user: IUser }) => void;
'afterSaveMessage': (
message: IMessage,
params: { room: IRoom; user: IUser; roomUpdater?: Updater<IRoom>; options?: SendMessageOptions },
) => void;
'afterOmnichannelSaveMessage': (message: IMessage, constant: { room: IOmnichannelRoom; roomUpdater: Updater<IOmnichannelRoom> }) => void;
'livechat.removeAgentDepartment': (params: { departmentId: ILivechatDepartmentRecord['_id']; agentsId: ILivechatAgent['_id'][] }) => void;
'livechat.saveAgentDepartment': (params: { departmentId: ILivechatDepartmentRecord['_id']; agentsId: ILivechatAgent['_id'][] }) => void;
'livechat.closeRoom': (params: { room: IOmnichannelRoom; options: CloseRoomParams['options'] }) => void;
'livechat:afterReturnRoomAsInquiry': (params: { room: IRoom }) => void;
'livechat.setUserStatusLivechat': (params: { userId: IUser['_id']; status: OmnichannelAgentStatus }) => void;
'livechat.agentStatusChanged': (params: { userId: IUser['_id']; status: UserStatus }) => void;
'livechat.onNewAgentCreated': (agentId: string) => void;
'livechat.afterAgentRemoved': (params: { agent: Pick<IUser, '_id' | 'username'> }) => void;
'afterAddedToRoom': (params: { user: IUser; inviter?: IUser }, room: IRoom) => void;
'beforeAddedToRoom': (params: {
user: AtLeast<IUser, '_id' | 'federated' | 'roles'>;
inviter: AtLeast<IUser, '_id' | 'username'>;
}) => void;
'afterCreateDirectRoom': (params: IRoom, second: { members: IUser[]; creatorId: IUser['_id']; mrid?: string }) => void;
'beforeDeleteRoom': (params: IRoom) => void;
'beforeJoinDefaultChannels': (user: IUser) => void;
'beforeCreateChannel': (owner: IUser, room: IRoom) => void;
'afterCreateRoom': (owner: IUser, room: IRoom) => void;
'onValidateLogin': (login: ILoginAttempt) => void;
'federation.afterCreateFederatedRoom': (
room: IRoom,
second: {
owner: IUser;
originalMemberList: string[];
options?: ICreateRoomOptions;
},
) => void;
'beforeCreateDirectRoom': (members: string[], room: IRoom) => void;
'federation.beforeCreateDirectMessage': (members: IUser[], extraData: Record<string, unknown>) => void;
'afterSetReaction': (message: IMessage, params: { user: IUser; reaction: string; shouldReact: boolean; room: IRoom }) => void;
'afterUnsetReaction': (
message: IMessage,
params: { user: IUser; reaction: string; shouldReact: boolean; oldMessage: IMessage; room: IRoom },
) => void;
'onJoinVideoConference': (callId: VideoConference['_id'], userId?: IUser['_id']) => Promise<void>;
'beforeJoinRoom': (user: IUser, room: IRoom) => void;
'beforeMuteUser': (users: { mutedUser: IUser; fromUser: IUser }, room: IRoom) => void;
'afterMuteUser': (users: { mutedUser: IUser; fromUser: IUser }, room: IRoom) => void;
'beforeUnmuteUser': (users: { mutedUser: IUser; fromUser: IUser }, room: IRoom) => void;
'afterUnmuteUser': (users: { mutedUser: IUser; fromUser: IUser }, room: IRoom) => void;
'afterValidateLogin': (login: { user: IUser }) => void;
'afterJoinRoom': (user: IUser, room: IRoom) => void;
'livechat.afterDepartmentDisabled': (department: ILivechatDepartmentRecord) => void;
'livechat.afterDepartmentArchived': (department: Pick<ILivechatDepartmentRecord, '_id' | 'businessHourId'>) => void;
'beforeSaveUser': ({ user, oldUser }: { user: IUser; oldUser?: IUser }) => void;
'afterCreateUser': (user: AtLeast<IUser, '_id' | 'username' | 'roles'>) => void;
'afterSaveUser': ({ user, oldUser }: { user: IUser; oldUser?: IUser | null }) => void;
'livechat.afterTagRemoved': (tag: ILivechatTagRecord) => void;
'afterUserImport': (data: { inserted: IUser['_id'][]; updated: IUser['_id']; skipped: number; failed: number }) => void;
}
/**
* Callbacks that are supposed to be composed like a chain.
*
* TODO: develop a middleware alternative and grant independence of execution order
*/
type ChainedCallbackSignatures = {
'livechat.beforeForwardRoomToDepartment': <T extends { room: IOmnichannelRoom; transferData?: { department: { _id: string } } }>(
options: T,
) => Promise<T>;
'livechat.onLoadForwardDepartmentRestrictions': (params: { departmentId: string }) => Record<string, unknown>;
'livechat.saveInfo': (
newRoom: IOmnichannelRoom,
props: { user: Required<Pick<IUser, '_id' | 'username' | 'name'>>; oldRoom: IOmnichannelRoom },
) => IOmnichannelRoom;
'afterDeleteRoom': (rid: IRoom['_id']) => IRoom['_id'];
'livechat:afterOnHold': (room: Pick<IOmnichannelRoom, '_id'>) => Pick<IOmnichannelRoom, '_id'>;
'livechat:afterOnHoldChatResumed': (room: Pick<IOmnichannelRoom, '_id'>) => Pick<IOmnichannelRoom, '_id'>;
'livechat:onTransferFailure': (
room: IRoom,
params: {
guest: ILivechatVisitor;
transferData: TransferData;
department: AtLeast<ILivechatDepartmentRecord, '_id' | 'fallbackForwardDepartment' | 'name'>;
},
) => IOmnichannelRoom | Promise<boolean>;
'livechat.afterForwardChatToAgent': (params: {
rid: IRoom['_id'];
servedBy: { _id: string; ts: Date; username?: string };
oldServedBy: { _id: string; ts: Date; username?: string };
}) => {
rid: IRoom['_id'];
servedBy: { _id: string; ts: Date; username?: string };
oldServedBy: { _id: string; ts: Date; username?: string };
};
'livechat.afterForwardChatToDepartment': (params: {
rid: IRoom['_id'];
newDepartmentId: ILivechatDepartmentRecord['_id'];
oldDepartmentId: ILivechatDepartmentRecord['_id'];
}) => {
rid: IRoom['_id'];
newDepartmentId: ILivechatDepartmentRecord['_id'];
oldDepartmentId: ILivechatDepartmentRecord['_id'];
};
'livechat.afterRemoveDepartment': (params: {
department: AtLeast<ILivechatDepartment, '_id' | 'businessHourId'>;
agentsId: ILivechatAgent['_id'][];
}) => {
department: AtLeast<ILivechatDepartment, '_id' | 'businessHourId'>;
agentsId: ILivechatAgent['_id'][];
};
'livechat.applySimultaneousChatRestrictions': (_: undefined, params: { departmentId?: ILivechatDepartmentRecord['_id'] }) => undefined;
'livechat.applyRoomRestrictions': (
query: FilterOperators<IOmnichannelRoom>,
params?: {
unitsFilter?: string[];
userId?: string;
},
) => FilterOperators<IOmnichannelRoom>;
'livechat.onMaxNumberSimultaneousChatsReached': (inquiry: ILivechatInquiryRecord) => ILivechatInquiryRecord;
'on-business-hour-start': (params: { BusinessHourBehaviorClass: { new (): IBusinessHourBehavior } }) => {
BusinessHourBehaviorClass: { new (): IBusinessHourBehavior };
};
'renderMessage': <T extends IMessage & { html: string }>(message: T) => T;
'livechat.beforeListTags': () => ILivechatTag[];
'livechat.offlineMessage': (data: { name: string; email: string; message: string; department?: string; host?: string }) => void;
'livechat.leadCapture': (room: IOmnichannelRoom) => IOmnichannelRoom;
'beforeSendMessageNotifications': (message: string) => string;
'livechat.onAgentAssignmentFailed': (
room: IOmnichannelRoom,
params: {
inquiry: {
_id: string;
rid: string;
status: string;
};
options: { forwardingToDepartment?: { oldDepartmentId?: string; transferData?: any }; clientAction?: boolean };
},
) => Promise<(IOmnichannelRoom & { chatQueued: boolean }) | false>;
'beforeGetMentions': (mentionIds: string[], teamMentions: MessageMention[]) => Promise<string[]>;
'livechat.manageDepartmentUnit': (params: { userId: string; departmentId: string; unitId?: string }) => void;
'afterRoomTopicChange': (
params: undefined,
{ room, topic, user }: { room: IRoom; topic: string; user: Pick<IUser, 'username' | '_id' | 'federation' | 'federated'> },
) => void;
'livechat.beforeInquiry': (data: IOmnichannelInquiryExtraData) => Partial<ILivechatInquiryRecord>;
};
export type Hook =
| keyof EventLikeCallbackSignatures
| keyof ChainedCallbackSignatures
| 'afterProcessOAuthUser'
| 'afterRoomArchived'
| 'afterSaveUser'
| 'afterValidateNewOAuthUser'
| 'beforeActivateUser'
| 'beforeReadMessages'
| 'beforeRemoveFromRoom'
| 'beforeValidateLogin'
| 'livechat.beforeForwardRoomToDepartment'
| 'livechat.checkAgentBeforeTakeInquiry'
| 'livechat.sendTranscript'
| 'livechat.closeRoom'
| 'livechat.offlineMessage'
| 'livechat.manageDepartmentUnit'
| 'mapLDAPUserData'
| 'onCreateUser'
| 'onLDAPLogin'
| 'renderNotification'
| 'userRegistered'
| 'test';
/**
* Callback hooks provide an easy way to add extra steps to common operations.
* @deprecated
*/
export const callbacks = new Callbacks<
{
[key in keyof ChainedCallbackSignatures]: ChainedCallbackSignatures[key];
},
{
[key in keyof EventLikeCallbackSignatures]: EventLikeCallbackSignatures[key];
},
Hook
>();

View File

@@ -0,0 +1,6 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { Callbacks } from './callbacksBase';
export const afterBanFromRoomCallback =
Callbacks.create<(data: { bannedUser: IUser; userWhoBanned: IUser }, room: IRoom) => void>('afterBanFromRoom');

View File

@@ -0,0 +1,5 @@
import type { IUser, IRoom } from '@rocket.chat/core-typings';
import { Callbacks } from './callbacksBase';
export const afterLeaveRoomCallback = Callbacks.create<(data: { user: IUser; kicker?: IUser }, room: IRoom) => void>('afterLeaveRoom');

View File

@@ -0,0 +1,4 @@
import { Callbacks } from './callbacksBase';
/** @deprecated - replace with UserProvider onLogout callback */
export const afterLogoutCleanUpCallback = Callbacks.create('afterLogoutCleanUp');

View File

@@ -0,0 +1,6 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { Callbacks } from './callbacksBase';
export const afterRemoveFromRoomCallback =
Callbacks.create<(data: { removedUser: IUser; userWhoRemoved: IUser }, room: IRoom) => void>('afterRemoveFromRoom');

View File

@@ -0,0 +1,6 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { Callbacks } from './callbacksBase';
export const afterUnbanFromRoomCallback =
Callbacks.create<(data: { unbannedUser: IUser; userWhoUnbanned: IUser }, room: IRoom) => void>('afterUnbanFromRoom');

View File

@@ -0,0 +1,7 @@
import type { IUser, IRoom } from '@rocket.chat/core-typings';
import { Callbacks } from './callbacksBase';
export const beforeAddUserToRoom = Callbacks.create<(args: { user: IUser; inviter?: IUser }, room: IRoom) => void>('beforeAddUserToRoom');
export const beforeAddUsersToRoom =
Callbacks.create<(args: { usernames: string[]; inviter?: IUser }, room: IRoom) => void>('beforeAddUsersToRoom');

Some files were not shown because too many files have changed in this diff Show More