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
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:
17
apps/meteor/server/lib/2fa/MethodInvocationOverride.js
Normal file
17
apps/meteor/server/lib/2fa/MethodInvocationOverride.js
Normal 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;
|
||||
70
apps/meteor/server/lib/2fa/code/EmailCheck.spec.ts
Normal file
70
apps/meteor/server/lib/2fa/code/EmailCheck.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
150
apps/meteor/server/lib/2fa/code/EmailCheck.ts
Normal file
150
apps/meteor/server/lib/2fa/code/EmailCheck.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
38
apps/meteor/server/lib/2fa/code/EmailCheckForOAuth.ts
Normal file
38
apps/meteor/server/lib/2fa/code/EmailCheckForOAuth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
22
apps/meteor/server/lib/2fa/code/ICodeCheck.ts
Normal file
22
apps/meteor/server/lib/2fa/code/ICodeCheck.ts
Normal 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>;
|
||||
}
|
||||
48
apps/meteor/server/lib/2fa/code/PasswordCheckFallback.ts
Normal file
48
apps/meteor/server/lib/2fa/code/PasswordCheckFallback.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
43
apps/meteor/server/lib/2fa/code/TOTPCheck.ts
Normal file
43
apps/meteor/server/lib/2fa/code/TOTPCheck.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
36
apps/meteor/server/lib/2fa/code/TOTPCheckForOAuth.ts
Normal file
36
apps/meteor/server/lib/2fa/code/TOTPCheckForOAuth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
193
apps/meteor/server/lib/2fa/code/checkCodeForUser.spec.ts
Normal file
193
apps/meteor/server/lib/2fa/code/checkCodeForUser.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
135
apps/meteor/server/lib/2fa/code/index.spec.ts
Normal file
135
apps/meteor/server/lib/2fa/code/index.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
268
apps/meteor/server/lib/2fa/code/index.ts
Normal file
268
apps/meteor/server/lib/2fa/code/index.ts
Normal 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;
|
||||
}
|
||||
84
apps/meteor/server/lib/2fa/functions/resetTOTP.ts
Normal file
84
apps/meteor/server/lib/2fa/functions/resetTOTP.ts
Normal 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;
|
||||
}
|
||||
154
apps/meteor/server/lib/2fa/functions/totp.ts
Normal file
154
apps/meteor/server/lib/2fa/functions/totp.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
78
apps/meteor/server/lib/2fa/lib/totp.ts
Normal file
78
apps/meteor/server/lib/2fa/lib/totp.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
110
apps/meteor/server/lib/2fa/loginHandler.ts
Normal file
110
apps/meteor/server/lib/2fa/loginHandler.ts
Normal 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);
|
||||
};
|
||||
51
apps/meteor/server/lib/2fa/twoFactorRequired.ts
Normal file
51
apps/meteor/server/lib/2fa/twoFactorRequired.ts
Normal 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>;
|
||||
69
apps/meteor/server/lib/OutboundMessageProvider.ts
Normal file
69
apps/meteor/server/lib/OutboundMessageProvider.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
17
apps/meteor/server/lib/RateLimiter.js
Normal file
17
apps/meteor/server/lib/RateLimiter.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
297
apps/meteor/server/lib/auditServerEvents/userChanged.spec.ts
Normal file
297
apps/meteor/server/lib/auditServerEvents/userChanged.spec.ts
Normal 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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
168
apps/meteor/server/lib/auditServerEvents/userChanged.ts
Normal file
168
apps/meteor/server/lib/auditServerEvents/userChanged.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
124
apps/meteor/server/lib/auth-providers/apple/loginHandler.spec.ts
Normal file
124
apps/meteor/server/lib/auth-providers/apple/loginHandler.spec.ts
Normal 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', '');
|
||||
});
|
||||
});
|
||||
46
apps/meteor/server/lib/auth-providers/apple/loginHandler.ts
Normal file
46
apps/meteor/server/lib/auth-providers/apple/loginHandler.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
});
|
||||
424
apps/meteor/server/lib/auth-providers/crowd/crowd.ts
Normal file
424
apps/meteor/server/lib/auth-providers/crowd/crowd.ts
Normal 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());
|
||||
}
|
||||
});
|
||||
});
|
||||
3
apps/meteor/server/lib/auth-providers/crowd/logger.ts
Normal file
3
apps/meteor/server/lib/auth-providers/crowd/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
|
||||
export const logger = new Logger('CROWD');
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
};
|
||||
7
apps/meteor/server/lib/auth-providers/custom-oauth/custom_oauth_server.d.ts
vendored
Normal file
7
apps/meteor/server/lib/auth-providers/custom-oauth/custom_oauth_server.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
91
apps/meteor/server/lib/auth-providers/dolphin.ts
Normal file
91
apps/meteor/server/lib/auth-providers/dolphin.ts
Normal 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');
|
||||
});
|
||||
23
apps/meteor/server/lib/auth-providers/drupal.README.md
Normal file
23
apps/meteor/server/lib/auth-providers/drupal.README.md
Normal 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+
|
||||
66
apps/meteor/server/lib/auth-providers/drupal.ts
Normal file
66
apps/meteor/server/lib/auth-providers/drupal.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
28
apps/meteor/server/lib/auth-providers/github-enterprise.ts
Normal file
28
apps/meteor/server/lib/auth-providers/github-enterprise.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
20
apps/meteor/server/lib/auth-providers/github.ts
Normal file
20
apps/meteor/server/lib/auth-providers/github.ts
Normal 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);
|
||||
69
apps/meteor/server/lib/auth-providers/gitlab.ts
Normal file
69
apps/meteor/server/lib/auth-providers/gitlab.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
106
apps/meteor/server/lib/auth-providers/google.js
Normal file
106
apps/meteor/server/lib/auth-providers/google.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\'/g, '"')
|
||||
.replace(/\'/g, ''')
|
||||
.replace(/\//g, '/');
|
||||
};
|
||||
|
||||
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',
|
||||
);
|
||||
};
|
||||
});
|
||||
37
apps/meteor/server/lib/auth-providers/iframe.ts
Normal file
37
apps/meteor/server/lib/auth-providers/iframe.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
49
apps/meteor/server/lib/auth-providers/linkedin.ts
Normal file
49
apps/meteor/server/lib/auth-providers/linkedin.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
46
apps/meteor/server/lib/auth-providers/meteor-developer.ts
Normal file
46
apps/meteor/server/lib/auth-providers/meteor-developer.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
68
apps/meteor/server/lib/auth-providers/oauth/facebook.js
Normal file
68
apps/meteor/server/lib/auth-providers/oauth/facebook.js
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
83
apps/meteor/server/lib/auth-providers/oauth/google.js
Normal file
83
apps/meteor/server/lib/auth-providers/oauth/google.js
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
55
apps/meteor/server/lib/auth-providers/oauth/oauth.js
Normal file
55
apps/meteor/server/lib/auth-providers/oauth/oauth.js
Normal 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);
|
||||
});
|
||||
12
apps/meteor/server/lib/auth-providers/oauth/proxy.js
Normal file
12
apps/meteor/server/lib/auth-providers/oauth/proxy.js
Normal 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);
|
||||
});
|
||||
55
apps/meteor/server/lib/auth-providers/oauth/twitter.js
Normal file
55
apps/meteor/server/lib/auth-providers/oauth/twitter.js
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
115
apps/meteor/server/lib/auth-providers/wordpress.ts
Normal file
115
apps/meteor/server/lib/auth-providers/wordpress.ts
Normal 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());
|
||||
});
|
||||
27
apps/meteor/server/lib/auth/ILoginAttempt.ts
Normal file
27
apps/meteor/server/lib/auth/ILoginAttempt.ts
Normal 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;
|
||||
}
|
||||
31
apps/meteor/server/lib/auth/generatePassword.ts
Normal file
31
apps/meteor/server/lib/auth/generatePassword.ts
Normal 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 });
|
||||
};
|
||||
37
apps/meteor/server/lib/auth/logLoginAttempts.ts
Normal file
37
apps/meteor/server/lib/auth/logLoginAttempts.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
16
apps/meteor/server/lib/auth/loginErrorMessageOverride.ts
Normal file
16
apps/meteor/server/lib/auth/loginErrorMessageOverride.ts
Normal 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;
|
||||
};
|
||||
69
apps/meteor/server/lib/auth/oauth2-server/addOAuthApp.ts
Normal file
69
apps/meteor/server/lib/auth/oauth2-server/addOAuthApp.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
4
apps/meteor/server/lib/auth/oauth2-server/index.ts
Normal file
4
apps/meteor/server/lib/auth/oauth2-server/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import './oauth2-server';
|
||||
import './addOAuthApp';
|
||||
import '../../../meteor-methods/auth/updateOAuthApp';
|
||||
import '../../../meteor-methods/auth/deleteOAuthApp';
|
||||
85
apps/meteor/server/lib/auth/oauth2-server/oauth2-server.ts
Normal file
85
apps/meteor/server/lib/auth/oauth2-server/oauth2-server.ts
Normal 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);
|
||||
17
apps/meteor/server/lib/auth/oauth2-server/parseUriList.ts
Normal file
17
apps/meteor/server/lib/auth/oauth2-server/parseUriList.ts
Normal 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
|
||||
};
|
||||
64
apps/meteor/server/lib/auth/passwordPolicy.ts
Normal file
64
apps/meteor/server/lib/auth/passwordPolicy.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
);
|
||||
173
apps/meteor/server/lib/auth/restrictLoginAttempts.ts
Normal file
173
apps/meteor/server/lib/auth/restrictLoginAttempts.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
531
apps/meteor/server/lib/auth/startup.js
Normal file
531
apps/meteor/server/lib/auth/startup.js
Normal 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);
|
||||
});
|
||||
23
apps/meteor/server/lib/auth/token-login.js
Normal file
23
apps/meteor/server/lib/auth/token-login.js
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
41
apps/meteor/server/lib/authorization/README.md
Normal file
41
apps/meteor/server/lib/authorization/README.md
Normal 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.
|
||||
12
apps/meteor/server/lib/authorization/canAccessRoom.ts
Normal file
12
apps/meteor/server/lib/authorization/canAccessRoom.ts
Normal 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,
|
||||
};
|
||||
77
apps/meteor/server/lib/authorization/canDeleteMessage.ts
Normal file
77
apps/meteor/server/lib/authorization/canDeleteMessage.ts
Normal 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;
|
||||
};
|
||||
66
apps/meteor/server/lib/authorization/canSendMessage.ts
Normal file
66
apps/meteor/server/lib/authorization/canSendMessage.ts
Normal 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;
|
||||
}
|
||||
242
apps/meteor/server/lib/authorization/constant/permissions.ts
Normal file
242
apps/meteor/server/lib/authorization/constant/permissions.ts
Normal 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'] },
|
||||
];
|
||||
7
apps/meteor/server/lib/authorization/getRoles.ts
Normal file
7
apps/meteor/server/lib/authorization/getRoles.ts
Normal 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);
|
||||
51
apps/meteor/server/lib/authorization/getUsersInRole.ts
Normal file
51
apps/meteor/server/lib/authorization/getUsersInRole.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
24
apps/meteor/server/lib/authorization/hasPermission.ts
Normal file
24
apps/meteor/server/lib/authorization/hasPermission.ts
Normal 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);
|
||||
31
apps/meteor/server/lib/authorization/hasRole.ts
Normal file
31
apps/meteor/server/lib/authorization/hasRole.ts
Normal 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);
|
||||
7
apps/meteor/server/lib/authorization/index.ts
Normal file
7
apps/meteor/server/lib/authorization/index.ts
Normal 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 };
|
||||
14
apps/meteor/server/lib/authorization/isABACManagedRoom.ts
Normal file
14
apps/meteor/server/lib/authorization/isABACManagedRoom.ts
Normal 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;
|
||||
};
|
||||
83
apps/meteor/server/lib/authorization/permissionRole.ts
Normal file
83
apps/meteor/server/lib/authorization/permissionRole.ts
Normal 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);
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
123
apps/meteor/server/lib/authorization/upsertPermissions.ts
Normal file
123
apps/meteor/server/lib/authorization/upsertPermissions.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
5
apps/meteor/server/lib/autotranslate/README.md
Normal file
5
apps/meteor/server/lib/autotranslate/README.md
Normal 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.
|
||||
387
apps/meteor/server/lib/autotranslate/autotranslate.ts
Normal file
387
apps/meteor/server/lib/autotranslate/autotranslate.ts
Normal 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(`((?:<|<)(?:${urlSchemes}):\\/\\/[^\\|]+\\|)(.+?)(?=>|>)((?:>|>))`, '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  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);
|
||||
});
|
||||
});
|
||||
220
apps/meteor/server/lib/autotranslate/deeplTranslate.ts
Normal file
220
apps/meteor/server/lib/autotranslate/deeplTranslate.ts
Normal 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());
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
223
apps/meteor/server/lib/autotranslate/googleTranslate.ts
Normal file
223
apps/meteor/server/lib/autotranslate/googleTranslate.ts
Normal 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());
|
||||
13
apps/meteor/server/lib/autotranslate/index.ts
Normal file
13
apps/meteor/server/lib/autotranslate/index.ts
Normal 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 };
|
||||
179
apps/meteor/server/lib/autotranslate/libreTranslate.ts
Normal file
179
apps/meteor/server/lib/autotranslate/libreTranslate.ts
Normal 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());
|
||||
7
apps/meteor/server/lib/autotranslate/logger.ts
Normal file
7
apps/meteor/server/lib/autotranslate/logger.ts
Normal 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');
|
||||
208
apps/meteor/server/lib/autotranslate/msTranslate.ts
Normal file
208
apps/meteor/server/lib/autotranslate/msTranslate.ts
Normal 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());
|
||||
8
apps/meteor/server/lib/autotranslate/permissions.ts
Normal file
8
apps/meteor/server/lib/autotranslate/permissions.ts
Normal 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']);
|
||||
}
|
||||
});
|
||||
60
apps/meteor/server/lib/banUserFromRoom.ts
Normal file
60
apps/meteor/server/lib/banUserFromRoom.ts
Normal 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;
|
||||
};
|
||||
54
apps/meteor/server/lib/bot-helpers/README.md
Normal file
54
apps/meteor/server/lib/bot-helpers/README.md
Normal 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.
|
||||
214
apps/meteor/server/lib/bot-helpers/index.ts
Normal file
214
apps/meteor/server/lib/bot-helpers/index.ts
Normal 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' });
|
||||
},
|
||||
});
|
||||
43
apps/meteor/server/lib/bugsnag.ts
Normal file
43
apps/meteor/server/lib/bugsnag.ts
Normal 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;
|
||||
});
|
||||
71
apps/meteor/server/lib/callbacks.spec.ts
Normal file
71
apps/meteor/server/lib/callbacks.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
230
apps/meteor/server/lib/callbacks.ts
Normal file
230
apps/meteor/server/lib/callbacks.ts
Normal 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
|
||||
>();
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Callbacks } from './callbacksBase';
|
||||
|
||||
/** @deprecated - replace with UserProvider onLogout callback */
|
||||
export const afterLogoutCleanUpCallback = Callbacks.create('afterLogoutCleanUp');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
7
apps/meteor/server/lib/callbacks/beforeAddUserToRoom.ts
Normal file
7
apps/meteor/server/lib/callbacks/beforeAddUserToRoom.ts
Normal 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
Reference in New Issue
Block a user