feat: adds verification when inviting external matrixIds (#28096)

pull/29683/head^2
Luis Mauro 2 years ago committed by GitHub
parent 653987b85a
commit 19aec23cda
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      .changeset/serious-garlics-clean.md
  2. 1
      apps/meteor/app/api/server/index.ts
  3. 24
      apps/meteor/app/api/server/v1/federation.ts
  4. 83
      apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddMatrixUsers/AddMatrixUsersModal.tsx
  5. 39
      apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddMatrixUsers/useAddMatrixUsers.tsx
  6. 25
      apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.tsx
  7. 4
      apps/meteor/ee/server/local-services/federation/service.ts
  8. 1
      apps/meteor/server/services/federation/domain/IFederationBridge.ts
  9. 38
      apps/meteor/server/services/federation/infrastructure/matrix/Bridge.ts
  10. 4
      apps/meteor/server/services/federation/infrastructure/matrix/converters/room/RoomReceiver.ts
  11. 58
      apps/meteor/server/services/federation/infrastructure/matrix/helpers/HtttpStatusCodes.ts
  12. 25
      apps/meteor/server/services/federation/infrastructure/matrix/helpers/MatrixIdStringTools.ts
  13. 7
      apps/meteor/server/services/federation/infrastructure/matrix/helpers/MatrixIdVerificationTypes.ts
  14. 8
      apps/meteor/server/services/federation/service.ts
  15. 44
      apps/meteor/tests/unit/server/federation/infrastructure/matrix/Bridge.spec.ts
  16. 4
      packages/core-services/src/types/IFederationService.ts
  17. 22
      packages/rest-typings/src/v1/federation/FederationVerifyMatrixIdProps.ts
  18. 1
      packages/rest-typings/src/v1/federation/index.ts
  19. 4
      packages/rest-typings/src/v1/federation/rooms.ts

@ -0,0 +1,7 @@
---
'@rocket.chat/core-services': minor
'@rocket.chat/rest-typings': minor
'@rocket.chat/meteor': minor
---
New AddUser workflow for Federated Rooms

@ -46,6 +46,7 @@ import './v1/voip/extensions';
import './v1/voip/queues';
import './v1/voip/omnichannel';
import './v1/voip';
import './v1/federation';
import './v1/moderation';
export { API, APIClass, defaultRateLimiterOptions } from './api';

@ -0,0 +1,24 @@
import { Federation, FederationEE } from '@rocket.chat/core-services';
import { isFederationVerifyMatrixIdProps } from '@rocket.chat/rest-typings';
import { isEnterprise } from '../../../../ee/app/license/server';
import { API } from '../api';
API.v1.addRoute(
'federation/matrixIds.verify',
{
authRequired: true,
validateParams: isFederationVerifyMatrixIdProps,
},
{
async get() {
const { matrixIds } = this.queryParams;
const federationService = isEnterprise() ? FederationEE : Federation;
const results = await federationService.verifyMatrixIds(matrixIds);
return API.v1.success({ results: Object.fromEntries(results) });
},
},
);

@ -0,0 +1,83 @@
import { Modal, Button, Box, Icon } from '@rocket.chat/fuselage';
import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts';
import type { ComponentProps, ReactElement } from 'react';
import React from 'react';
import { useForm } from 'react-hook-form';
type AddMatrixUsersModalProps = {
matrixIdVerifiedStatus: Map<string, string>;
completeUserList: string[];
onClose: () => void;
onSave: (args_0: any) => Promise<void>;
};
type FormValues = {
usersToInvite: string[];
};
const verificationStatusAsIcon = (verificationStatus: string) => {
if (verificationStatus === 'VERIFIED') {
return 'circle-check';
}
if (verificationStatus === 'UNVERIFIED') {
return 'circle-cross';
}
if (verificationStatus === 'UNABLE_TO_VERIFY') {
return 'circle-exclamation';
}
};
const AddMatrixUsersModal = ({ onClose, matrixIdVerifiedStatus, onSave, completeUserList }: AddMatrixUsersModalProps): ReactElement => {
const dispatchToastMessage = useToastMessageDispatch();
const usersToInvite = completeUserList.filter(
(user) => !(matrixIdVerifiedStatus.has(user) && matrixIdVerifiedStatus.get(user) === 'UNVERIFIED'),
);
const { handleSubmit } = useForm<FormValues>({
defaultValues: {
usersToInvite,
},
});
const onSubmit = (data: FormValues) => {
onSave({ users: data.usersToInvite })
.then(onClose)
.catch((error) => dispatchToastMessage({ type: 'error', message: error as Error }));
};
const t = useTranslation();
return (
<Modal>
<Modal.Header>
<Modal.HeaderText>
<Modal.Title>Sending Invitations</Modal.Title>
</Modal.HeaderText>
<Modal.Close title={t('Close')} onClick={onClose} />
</Modal.Header>
<Modal.Content>
<Box>
<Box is='ul'>
{[...matrixIdVerifiedStatus.entries()].map(([_matrixId, _verificationStatus]) => (
<li key={_matrixId}>
{_matrixId}: <Icon name={verificationStatusAsIcon(_verificationStatus) as ComponentProps<typeof Icon>['name']} size='x20' />
</li>
))}
</Box>
</Box>
</Modal.Content>
<Modal.Footer justifyContent='center'>
<Modal.FooterControllers>
<Button onClick={onClose}>{t('Cancel')}</Button>
<Button primary onClick={handleSubmit(onSubmit)} disabled={!(usersToInvite.length > 0)}>
{t('Yes_continue')}
</Button>
</Modal.FooterControllers>
</Modal.Footer>
</Modal>
);
};
export default AddMatrixUsersModal;

@ -0,0 +1,39 @@
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useSetModal, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import React from 'react';
import AddMatrixUsersModal from './AddMatrixUsersModal';
export type useAddMatrixUsersProps = {
handleSave: (args_0: any) => Promise<void>;
users: string[];
};
export const useAddMatrixUsers = () => {
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const handleClose = useMutableCallback(() => setModal(null));
const dispatchVerifyEndpoint = useEndpoint('GET', '/v1/federation/matrixIds.verify');
return useMutation(async ({ users, handleSave }: useAddMatrixUsersProps) => {
try {
let matrixIdVerificationMap = new Map();
const matrixIds = users.filter((user) => user.startsWith('@'));
const matrixIdsVerificationResponse = await dispatchVerifyEndpoint({ matrixIds });
const { results: matrixIdsVerificationResults } = matrixIdsVerificationResponse;
matrixIdVerificationMap = new Map(Object.entries(matrixIdsVerificationResults));
setModal(
<AddMatrixUsersModal
completeUserList={users}
onClose={handleClose}
onSave={handleSave}
matrixIdVerifiedStatus={matrixIdVerificationMap as Map<string, string>}
/>,
);
} catch (error) {
dispatchToastMessage({ type: 'error', message: error as Error });
}
});
};

@ -19,6 +19,7 @@ import UserAutoCompleteMultiple from '../../../../../components/UserAutoComplete
import UserAutoCompleteMultipleFederated from '../../../../../components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated';
import { useRoom } from '../../../contexts/RoomContext';
import { useRoomToolbox } from '../../../contexts/RoomToolboxContext';
import { useAddMatrixUsers } from './AddMatrixUsers/useAddMatrixUsers';
type AddUsersProps = {
rid: IRoom['_id'];
@ -37,6 +38,7 @@ const AddUsers = ({ rid, onClickBack, reload }: AddUsersProps): ReactElement =>
const {
handleSubmit,
control,
getValues,
formState: { isDirty },
} = useForm({ defaultValues: { users: [] } });
@ -51,6 +53,8 @@ const AddUsers = ({ rid, onClickBack, reload }: AddUsersProps): ReactElement =>
}
});
const addClickHandler = useAddMatrixUsers();
return (
<>
<ContextualbarHeader>
@ -80,9 +84,24 @@ const AddUsers = ({ rid, onClickBack, reload }: AddUsersProps): ReactElement =>
</ContextualbarScrollableContent>
<ContextualbarFooter>
<ButtonGroup stretch>
<Button primary disabled={!isDirty} onClick={handleSubmit(handleSave)}>
{t('Add_users')}
</Button>
{isRoomFederated(room) ? (
<Button
primary
disabled={addClickHandler.isLoading}
onClick={() =>
addClickHandler.mutate({
users: getValues('users'),
handleSave,
})
}
>
{t('Add_users')}
</Button>
) : (
<Button primary disabled={!isDirty} onClick={handleSubmit(handleSave)}>
{t('Add_users')}
</Button>
)}
</ButtonGroup>
</ContextualbarFooter>
</>

@ -198,6 +198,10 @@ export class FederationServiceEE extends AbstractBaseFederationServiceEE impleme
);
}
public async verifyMatrixIds(matrixIds: string[]): Promise<Map<string, string>> {
return super.verifyMatrixIds(matrixIds);
}
static async createFederationService(): Promise<FederationServiceEE> {
const federationService = new FederationServiceEE();
await federationService.initialize();

@ -81,6 +81,7 @@ export interface IFederationBridge {
getRoomTopic(externalRoomId: string, externalUserId: string): Promise<string | undefined>;
setRoomName(externalRoomId: string, externalUserId: string, roomName: string): Promise<void>;
setRoomTopic(externalRoomId: string, externalUserId: string, roomTopic: string): Promise<void>;
verifyInviteeIds(matrixIds: string[]): Promise<Map<string, string>>;
getRoomData(
externalUserId: string,
externalRoomId: string,

@ -16,6 +16,9 @@ import { RoomMembershipChangedEventType } from './definitions/events/RoomMembers
import { MatrixEnumRelatesToRelType, MatrixEnumSendMessageType } from './definitions/events/RoomMessageSent';
import type { MatrixEventRoomNameChanged } from './definitions/events/RoomNameChanged';
import type { MatrixEventRoomTopicChanged } from './definitions/events/RoomTopicChanged';
import { HttpStatusCodes } from './helpers/HtttpStatusCodes';
import { extractUserIdAndHomeserverFromMatrixId } from './helpers/MatrixIdStringTools';
import { VerificationStatus, MATRIX_USER_IN_USE } from './helpers/MatrixIdVerificationTypes';
let MatrixUserInstance: any;
@ -166,6 +169,41 @@ export class MatrixBridge implements IFederationBridge {
}
}
public async verifyInviteeIds(matrixIds: string[]): Promise<Map<string, string>> {
const matrixIdVerificationMap = new Map();
const matrixIdsVerificationPromises = matrixIds.map((matrixId) => this.verifyInviteeId(matrixId));
const matrixIdsVerificationPromiseResponse = await Promise.allSettled(matrixIdsVerificationPromises);
const matrixIdsVerificationFulfilledResults = matrixIdsVerificationPromiseResponse
.filter((result): result is PromiseFulfilledResult<VerificationStatus> => result.status === 'fulfilled')
.map((result) => result.value);
matrixIds.forEach((matrixId, idx) => matrixIdVerificationMap.set(matrixId, matrixIdsVerificationFulfilledResults[idx]));
return matrixIdVerificationMap;
}
private async verifyInviteeId(externalInviteeId: string): Promise<VerificationStatus> {
const [userId, homeserverUrl] = extractUserIdAndHomeserverFromMatrixId(externalInviteeId);
try {
const response = await fetch(`https://${homeserverUrl}/_matrix/client/v3/register/available`, { params: { username: userId } });
if (response.status === HttpStatusCodes.BAD_REQUEST) {
const responseBody = await response.json();
if (responseBody.errcode === MATRIX_USER_IN_USE) {
return VerificationStatus.VERIFIED;
}
}
if (response.status === HttpStatusCodes.OK) {
return VerificationStatus.UNVERIFIED;
}
} catch (e) {
return VerificationStatus.UNABLE_TO_VERIFY;
}
return VerificationStatus.UNABLE_TO_VERIFY;
}
public async createUser(username: string, name: string, domain: string, avatarUrl?: string): Promise<string> {
if (!MatrixUserInstance) {
throw new Error('Error loading the Matrix User instance from the external library');

@ -32,18 +32,22 @@ import type {
} from '../../definitions/events/RoomPowerLevelsChanged';
import type { MatrixEventRoomTopicChanged } from '../../definitions/events/RoomTopicChanged';
/** @deprecated export from {@link ../../helpers/MatrixIdStringTools} instead */
export const removeExternalSpecificCharsFromExternalIdentifier = (matrixIdentifier = ''): string => {
return matrixIdentifier.replace('@', '').replace('!', '').replace('#', '');
};
/** @deprecated export from {@link ../../helpers/MatrixIdStringTools} instead */
export const formatExternalUserIdToInternalUsernameFormat = (matrixUserId = ''): string => {
return matrixUserId.split(':')[0]?.replace('@', '');
};
export const isAnExternalIdentifierFormat = (identifier: string): boolean => identifier.includes(':');
/** @deprecated export from {@link ../../helpers/MatrixIdStringTools} instead */
export const isAnExternalUserIdFormat = (userId: string): boolean => isAnExternalIdentifierFormat(userId) && userId.includes('@');
/** @deprecated export from {@link ../../helpers/MatrixIdStringTools} instead */
export const extractServerNameFromExternalIdentifier = (identifier = ''): string => {
const splitted = identifier.split(':');

@ -0,0 +1,58 @@
export const enum HttpStatusCodes {
CONTINUE = 100,
SWITCHING_PROTOCOLS = 101,
PROCESSING = 102,
OK = 200,
CREATED = 201,
ACCEPTED = 202,
NON_AUTHORITATIVE_INFORMATION = 203,
NO_CONTENT = 204,
RESET_CONTENT = 205,
PARTIAL_CONTENT = 206,
MULTI_STATUS = 207,
MULTIPLE_CHOICES = 300,
MOVED_PERMANENTLY = 301,
MOVED_TEMPORARILY = 302,
SEE_OTHER = 303,
NOT_MODIFIED = 304,
USE_PROXY = 305,
TEMPORARY_REDIRECT = 307,
PERMANENT_REDIRECT = 308,
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
PAYMENT_REQUIRED = 402,
FORBIDDEN = 403,
NOT_FOUND = 404,
METHOD_NOT_ALLOWED = 405,
NOT_ACCEPTABLE = 406,
PROXY_AUTHENTICATION_REQUIRED = 407,
REQUEST_TIMEOUT = 408,
CONFLICT = 409,
GONE = 410,
LENGTH_REQUIRED = 411,
PRECONDITION_FAILED = 412,
REQUEST_TOO_LONG = 413,
REQUEST_URI_TOO_LONG = 414,
UNSUPPORTED_MEDIA_TYPE = 415,
REQUESTED_RANGE_NOT_SATISFIABLE = 416,
EXPECTATION_FAILED = 417,
IM_A_TEAPOT = 418,
INSUFFICIENT_SPACE_ON_RESOURCE = 419,
METHOD_FAILURE = 420,
MISDIRECTED_REQUEST = 421,
UNPROCESSABLE_ENTITY = 422,
LOCKED = 423,
FAILED_DEPENDENCY = 424,
PRECONDITION_REQUIRED = 428,
TOO_MANY_REQUESTS = 429,
REQUEST_HEADER_FIELDS_TOO_LARGE = 431,
UNAVAILABLE_FOR_LEGAL_REASONS = 451,
INTERNAL_SERVER_ERROR = 500,
NOT_IMPLEMENTED = 501,
BAD_GATEWAY = 502,
SERVICE_UNAVAILABLE = 503,
GATEWAY_TIMEOUT = 504,
HTTP_VERSION_NOT_SUPPORTED = 505,
INSUFFICIENT_STORAGE = 507,
NETWORK_AUTHENTICATION_REQUIRED = 511,
}

@ -0,0 +1,25 @@
export const removeExternalSpecificCharsFromExternalIdentifier = (matrixId = ''): string => {
return matrixId.replace('@', '').replace('!', '').replace('#', '');
};
export const formatExternalUserIdToInternalUsernameFormat = (matrixId = ''): string => {
return matrixId.split(':')[0]?.replace('@', '');
};
export const formatExternalAliasIdToInternalFormat = (alias = ''): string => {
return alias.split(':')[0]?.replace('#', '');
};
export const isAnExternalIdentifierFormat = (identifier: string): boolean => identifier.includes(':');
export const isAnExternalUserIdFormat = (userId: string): boolean => isAnExternalIdentifierFormat(userId) && userId.includes('@');
export const extractServerNameFromExternalIdentifier = (identifier = ''): string => {
const splitted = identifier.split(':');
return splitted.length > 1 ? splitted[1] : '';
};
export const extractUserIdAndHomeserverFromMatrixId = (matrixId = ''): string[] => {
return matrixId.replace('@', '').split(':');
};

@ -0,0 +1,7 @@
export const enum VerificationStatus {
VERIFIED = 'VERIFIED',
UNVERIFIED = 'UNVERIFIED',
UNABLE_TO_VERIFY = 'UNABLE_TO_VERIFY',
}
export const MATRIX_USER_IN_USE = 'M_USER_IN_USE';

@ -230,6 +230,10 @@ export abstract class AbstractFederationService extends ServiceClassInternal {
protected async cleanUpHandlers(): Promise<void> {
this.internalQueueInstance.setHandler(this.noop.bind(this), this.PROCESSING_CONCURRENCY);
}
protected async verifyMatrixIds(matrixIds: string[]): Promise<Map<string, string>> {
return this.bridge.verifyInviteeIds(matrixIds);
}
}
abstract class AbstractBaseFederationService extends AbstractFederationService {
@ -317,6 +321,10 @@ export class FederationService extends AbstractBaseFederationService implements
);
}
public async verifyMatrixIds(matrixIds: string[]): Promise<Map<string, string>> {
return super.verifyMatrixIds(matrixIds);
}
static async createFederationService(): Promise<FederationService> {
const federationService = new FederationService();
await federationService.initialize();

@ -1,10 +1,14 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import { VerificationStatus } from '../../../../../../server/services/federation/infrastructure/matrix/helpers/MatrixIdVerificationTypes';
const fetchStub = {
serverFetch: () => Promise.resolve({}),
};
const { MatrixBridge } = proxyquire.noCallThru().load('../../../../../../server/services/federation/infrastructure/matrix/Bridge', {
'meteor/fetch': {
'@global': true,
},
'@rocket.chat/server-fetch': fetchStub,
});
describe('Federation - Infrastructure - Matrix - Bridge', () => {
@ -49,4 +53,38 @@ describe('Federation - Infrastructure - Matrix - Bridge', () => {
expect(bridge.isRoomFromTheSameHomeserver('!room:server2.com', 'server.com')).to.be.false;
});
});
describe('#verifyInviteeId()', () => {
it('should return `VERIFIED` when the matrixId exists', async () => {
fetchStub.serverFetch = () => Promise.resolve({ status: 400, json: () => Promise.resolve({ errcode: 'M_USER_IN_USE' }) });
const verificationStatus = await bridge.verifyInviteeId('@user:server.com');
expect(verificationStatus).to.be.equal(VerificationStatus.VERIFIED);
});
it('should return `UNVERIFIED` when the matrixId does not exists', async () => {
fetchStub.serverFetch = () => Promise.resolve({ status: 200, json: () => Promise.resolve({}) });
const verificationStatus = await bridge.verifyInviteeId('@user:server.com');
expect(verificationStatus).to.be.equal(VerificationStatus.UNVERIFIED);
});
it('should return `UNABLE_TO_VERIFY` when the fetch() call fails', async () => {
fetchStub.serverFetch = () => Promise.reject(new Error('Error'));
const verificationStatus = await bridge.verifyInviteeId('@user:server.com');
expect(verificationStatus).to.be.equal(VerificationStatus.UNABLE_TO_VERIFY);
});
it('should return `UNABLE_TO_VERIFY` when an unexepected status comes', async () => {
fetchStub.serverFetch = () => Promise.resolve({ status: 500 });
const verificationStatus = await bridge.verifyInviteeId('@user:server.com');
expect(verificationStatus).to.be.equal(VerificationStatus.UNABLE_TO_VERIFY);
});
});
});

@ -2,6 +2,8 @@ import type { FederationPaginatedResult, IFederationPublicRooms } from '@rocket.
export interface IFederationService {
createDirectMessageRoomAndInviteUser(internalInviterId: string, internalRoomId: string, externalInviteeId: string): Promise<void>;
verifyMatrixIds(matrixIds: string[]): Promise<Map<string, string>>;
}
export interface IFederationJoinExternalPublicRoomInput {
@ -34,4 +36,6 @@ export interface IFederationServiceEE {
scheduleJoinExternalPublicRoom(internalUserId: string, externalRoomId: string, roomName?: string, pageToken?: string): Promise<void>;
joinExternalPublicRoom(input: IFederationJoinExternalPublicRoomInput): Promise<void>;
verifyMatrixIds(matrixIds: string[]): Promise<Map<string, string>>;
}

@ -0,0 +1,22 @@
import Ajv from 'ajv';
const ajv = new Ajv();
export type FederationVerifyMatrixIdProps = {
matrixIds: string[];
};
const FederationVerifyMatrixIdPropsSchema = {
type: 'object',
properties: {
matrixIds: {
type: 'array',
items: [{ type: 'string' }],
uniqueItems: true,
},
},
additionalProperties: false,
required: ['matrixIds'],
};
export const isFederationVerifyMatrixIdProps = ajv.compile<FederationVerifyMatrixIdProps>(FederationVerifyMatrixIdPropsSchema);

@ -15,3 +15,4 @@ export * from './FederationJoinExternalPublicRoomProps';
export * from './FederationPublicRoomsProps';
export * from './FederationAddServerProps';
export * from './FederationRemoveServerProps';
export * from './FederationVerifyMatrixIdProps';

@ -1,6 +1,7 @@
import type { FederationAddServerProps, FederationPaginatedResult, FederationRemoveServerProps } from '.';
import type { FederationJoinExternalPublicRoomProps } from './FederationJoinExternalPublicRoomProps';
import type { FederationSearchPublicRoomsProps } from './FederationPublicRoomsProps';
import type { FederationVerifyMatrixIdProps } from './FederationVerifyMatrixIdProps';
export interface IFederationPublicRooms {
id: string;
@ -30,4 +31,7 @@ export type FederationEndpoints = {
'/v1/federation/removeServerByUser': {
POST: (params: FederationRemoveServerProps) => void;
};
'/v1/federation/matrixIds.verify': {
GET: (params: FederationVerifyMatrixIdProps) => { results: Map<string, string> };
};
};

Loading…
Cancel
Save