You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1211 lines
37 KiB
1211 lines
37 KiB
import type { IMethodConnection, IUser } from '@rocket.chat/core-typings';
|
|
import type { Route, Router } from '@rocket.chat/http-router';
|
|
import { License } from '@rocket.chat/license';
|
|
import { Logger } from '@rocket.chat/logger';
|
|
import { Users } from '@rocket.chat/models';
|
|
import { Random } from '@rocket.chat/random';
|
|
import type { JoinPathPattern, Method } from '@rocket.chat/rest-typings';
|
|
import { ajv } from '@rocket.chat/rest-typings';
|
|
import { wrapExceptions } from '@rocket.chat/tools';
|
|
import type { ValidateFunction } from 'ajv';
|
|
import { Accounts } from 'meteor/accounts-base';
|
|
import { DDP } from 'meteor/ddp';
|
|
// eslint-disable-next-line import/no-duplicates
|
|
import { DDPCommon } from 'meteor/ddp-common';
|
|
import { Meteor } from 'meteor/meteor';
|
|
import type { RateLimiterOptionsToCheck } from 'meteor/rate-limit';
|
|
// eslint-disable-next-line import/no-duplicates
|
|
import { RateLimiter } from 'meteor/rate-limit';
|
|
import _ from 'underscore';
|
|
|
|
import type { PermissionsPayload } from './api.helpers';
|
|
import { checkPermissionsForInvocation, checkPermissions, parseDeprecation } from './api.helpers';
|
|
import type {
|
|
FailureResult,
|
|
ForbiddenResult,
|
|
InnerAction,
|
|
InternalError,
|
|
NotFoundResult,
|
|
Operations,
|
|
Options,
|
|
SuccessResult,
|
|
TypedThis,
|
|
TypedAction,
|
|
TypedOptions,
|
|
UnauthorizedResult,
|
|
RedirectStatusCodes,
|
|
RedirectResult,
|
|
UnavailableResult,
|
|
GenericRouteExecutionContext,
|
|
} from './definition';
|
|
import { getUserInfo } from './helpers/getUserInfo';
|
|
import { parseJsonQuery } from './helpers/parseJsonQuery';
|
|
import { RocketChatAPIRouter } from './router';
|
|
import { license } from '../../../ee/app/api-enterprise/server/middlewares/license';
|
|
import { isObject } from '../../../lib/utils/isObject';
|
|
import { getNestedProp } from '../../../server/lib/getNestedProp';
|
|
import { shouldBreakInVersion } from '../../../server/lib/shouldBreakInVersion';
|
|
import { checkCodeForUser } from '../../2fa/server/code';
|
|
import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission';
|
|
import { notifyOnUserChangeAsync } from '../../lib/server/lib/notifyListener';
|
|
import { settings } from '../../settings/server';
|
|
import { getDefaultUserFields } from '../../utils/server/functions/getDefaultUserFields';
|
|
|
|
const logger = new Logger('API');
|
|
|
|
// We have some breaking changes planned to the API.
|
|
// To avoid conflicts or missing something during the period we are adopting a 'feature flag approach'
|
|
// TODO: MAJOR check if this is still needed
|
|
const applyBreakingChanges = shouldBreakInVersion('9.0.0');
|
|
type MinimalRoute = {
|
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
path: string;
|
|
} & TypedOptions;
|
|
|
|
export type Prettify<T> = {
|
|
[K in keyof T]: T[K];
|
|
} & unknown;
|
|
|
|
type ExtractValidation<T> = T extends ValidateFunction<infer TSchema> ? TSchema : never;
|
|
|
|
type UnionToIntersection<U> = (U extends any ? (x: U) => any : never) extends (x: infer I) => any ? I : never;
|
|
|
|
export type ExtractRoutesFromAPI<T> = Prettify<
|
|
UnionToIntersection<
|
|
T extends APIClass<any, infer TOperations> ? (TOperations extends MinimalRoute ? Prettify<ConvertToRoute<TOperations>> : never) : never
|
|
>
|
|
>;
|
|
|
|
type ConvertToRoute<TRoute extends MinimalRoute> = {
|
|
[K in TRoute['path']]: {
|
|
[K2 in Extract<TRoute, { path: K }>['method']]: K2 extends 'GET' | 'DELETE'
|
|
? (
|
|
...args: [ExtractValidation<Extract<TRoute, { path: K; method: K2 }>['query']>] extends [never]
|
|
? [params?: never]
|
|
: [params: ExtractValidation<Extract<TRoute, { path: K; method: K2 }>['query']>]
|
|
) => ExtractValidation<Extract<TRoute, { path: K; method: K2 }>['response'][200]>
|
|
: K2 extends 'POST' | 'PUT'
|
|
? (
|
|
params: ExtractValidation<Extract<TRoute, { path: K; method: K2 }>['body']>,
|
|
) => ExtractValidation<
|
|
200 extends keyof Extract<TRoute, { path: K; method: K2 }>['response']
|
|
? Extract<TRoute, { path: K; method: K2 }>['response'][200]
|
|
: 201 extends keyof Extract<TRoute, { path: K; method: K2 }>['response']
|
|
? Extract<TRoute, { path: K; method: K2 }>['response'][201]
|
|
: never
|
|
>
|
|
: never;
|
|
};
|
|
};
|
|
|
|
interface IAPIProperties {
|
|
useDefaultAuth: boolean;
|
|
prettyJson: boolean;
|
|
version?: string;
|
|
enableCors?: boolean;
|
|
apiPath?: string;
|
|
}
|
|
|
|
interface IAPIDefaultFieldsToExclude {
|
|
avatarOrigin: number;
|
|
emails: number;
|
|
phone: number;
|
|
statusConnection: number;
|
|
createdAt: number;
|
|
lastLogin: number;
|
|
services: number;
|
|
requirePasswordChange: number;
|
|
requirePasswordChangeReason: number;
|
|
roles: number;
|
|
statusDefault: number;
|
|
_updatedAt: number;
|
|
settings: number;
|
|
inviteToken: number;
|
|
}
|
|
|
|
export type RateLimiterOptions = {
|
|
numRequestsAllowed?: number;
|
|
intervalTimeInMS?: number;
|
|
};
|
|
|
|
export const defaultRateLimiterOptions: RateLimiterOptions = {
|
|
numRequestsAllowed: settings.get<number>('API_Enable_Rate_Limiter_Limit_Calls_Default'),
|
|
intervalTimeInMS: settings.get<number>('API_Enable_Rate_Limiter_Limit_Time_Default'),
|
|
};
|
|
const rateLimiterDictionary: Record<
|
|
string,
|
|
{
|
|
rateLimiter: RateLimiter;
|
|
options: RateLimiterOptions;
|
|
}
|
|
> = {};
|
|
|
|
const generateConnection = (
|
|
ipAddress: string,
|
|
httpHeaders: Record<string, any>,
|
|
): {
|
|
id: string;
|
|
close: () => void;
|
|
clientAddress: string;
|
|
httpHeaders: Record<string, any>;
|
|
} => ({
|
|
id: Random.id(),
|
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
close() {},
|
|
httpHeaders,
|
|
clientAddress: ipAddress,
|
|
});
|
|
|
|
export class APIClass<TBasePath extends string = '', TOperations extends Record<string, unknown> = Record<string, never>> {
|
|
public typedRoutes: Record<string, Record<string, Route>> = {};
|
|
|
|
protected apiPath?: string;
|
|
|
|
readonly version?: string;
|
|
|
|
private _routes: { path: string; options: Options; endpoints: Record<string, string> }[] = [];
|
|
|
|
public authMethods: ((routeContext: GenericRouteExecutionContext) => Promise<IUser | undefined>)[];
|
|
|
|
protected helperMethods: Map<string, () => any> = new Map();
|
|
|
|
public fieldSeparator: string;
|
|
|
|
public defaultFieldsToExclude: {
|
|
joinCode: number;
|
|
members: number;
|
|
importIds: number;
|
|
e2e: number;
|
|
};
|
|
|
|
public defaultLimitedUserFieldsToExclude: IAPIDefaultFieldsToExclude;
|
|
|
|
public limitedUserFieldsToExclude: IAPIDefaultFieldsToExclude;
|
|
|
|
public limitedUserFieldsToExcludeIfIsPrivilegedUser: {
|
|
services: number;
|
|
inviteToken: number;
|
|
};
|
|
|
|
readonly router: Router<any, any, any>;
|
|
|
|
constructor({ useDefaultAuth, ...properties }: IAPIProperties) {
|
|
this.version = properties.version;
|
|
|
|
this.apiPath = [properties.apiPath, properties.version].filter(Boolean).join('/').replaceAll('//', '/');
|
|
this.authMethods = [];
|
|
this.fieldSeparator = '.';
|
|
this.defaultFieldsToExclude = {
|
|
joinCode: 0,
|
|
members: 0,
|
|
importIds: 0,
|
|
e2e: 0,
|
|
};
|
|
this.defaultLimitedUserFieldsToExclude = {
|
|
avatarOrigin: 0,
|
|
emails: 0,
|
|
phone: 0,
|
|
statusConnection: 0,
|
|
createdAt: 0,
|
|
lastLogin: 0,
|
|
services: 0,
|
|
requirePasswordChange: 0,
|
|
requirePasswordChangeReason: 0,
|
|
roles: 0,
|
|
statusDefault: 0,
|
|
_updatedAt: 0,
|
|
settings: 0,
|
|
inviteToken: 0,
|
|
};
|
|
this.limitedUserFieldsToExclude = this.defaultLimitedUserFieldsToExclude;
|
|
this.limitedUserFieldsToExcludeIfIsPrivilegedUser = {
|
|
services: 0,
|
|
inviteToken: 0,
|
|
};
|
|
this.router = new RocketChatAPIRouter(`/${this.apiPath}`.replace(/\/$/, '').replaceAll('//', '/'));
|
|
|
|
if (useDefaultAuth) {
|
|
this._initAuth();
|
|
}
|
|
}
|
|
|
|
public setLimitedCustomFields(customFields: string[]): void {
|
|
const nonPublicFieds = customFields.reduce(
|
|
(acc, customField) => {
|
|
acc[`customFields.${customField}`] = 0;
|
|
return acc;
|
|
},
|
|
{} as Record<string, any>,
|
|
);
|
|
this.limitedUserFieldsToExclude = {
|
|
...this.defaultLimitedUserFieldsToExclude,
|
|
...nonPublicFieds,
|
|
};
|
|
}
|
|
|
|
async parseJsonQuery(routeContext: GenericRouteExecutionContext) {
|
|
return parseJsonQuery(routeContext);
|
|
}
|
|
|
|
public addAuthMethod(func: (routeContext: GenericRouteExecutionContext) => Promise<IUser | undefined>): void {
|
|
this.authMethods.push(func);
|
|
}
|
|
|
|
protected shouldAddRateLimitToRoute(options: { rateLimiterOptions?: RateLimiterOptions | boolean }): boolean {
|
|
const { rateLimiterOptions } = options;
|
|
return (
|
|
(typeof rateLimiterOptions === 'object' || rateLimiterOptions === undefined) &&
|
|
Boolean(this.version) &&
|
|
!process.env.TEST_MODE &&
|
|
Boolean(defaultRateLimiterOptions.numRequestsAllowed && defaultRateLimiterOptions.intervalTimeInMS)
|
|
);
|
|
}
|
|
|
|
public success(): SuccessResult<void>;
|
|
|
|
public success<T>(result: T): SuccessResult<T>;
|
|
|
|
public success<T>(result: T = {} as T): SuccessResult<T> {
|
|
if (isObject(result)) {
|
|
(result as Record<string, any>).success = true;
|
|
}
|
|
|
|
const finalResult = {
|
|
statusCode: 200,
|
|
body: result,
|
|
} as SuccessResult<T>;
|
|
|
|
return finalResult as SuccessResult<T>;
|
|
}
|
|
|
|
public redirect<T, C extends RedirectStatusCodes>(code: C, result: T): RedirectResult<T, C> {
|
|
return {
|
|
statusCode: code,
|
|
body: result,
|
|
};
|
|
}
|
|
|
|
public failure<T>(result?: T): FailureResult<T>;
|
|
|
|
public failure<T, TErrorType extends string, TStack extends string, TErrorDetails>(
|
|
result?: T,
|
|
errorType?: TErrorType,
|
|
stack?: TStack,
|
|
error?: { details: TErrorDetails },
|
|
): FailureResult<T, TErrorType, TStack, TErrorDetails>;
|
|
|
|
public failure<T, TErrorType extends string, TStack extends string, TErrorDetails>(
|
|
result?: T,
|
|
errorType?: TErrorType,
|
|
stack?: TStack,
|
|
error?: { details: TErrorDetails },
|
|
): FailureResult<T> {
|
|
const response: {
|
|
statusCode: 400;
|
|
body: any & { message?: string; errorType?: string; stack?: string; success?: boolean; details?: Record<string, any> | string };
|
|
} = { statusCode: 400, body: result };
|
|
|
|
if (isObject(result)) {
|
|
response.body.success = false;
|
|
} else {
|
|
response.body = {
|
|
success: false,
|
|
error: result,
|
|
stack,
|
|
};
|
|
|
|
if (errorType) {
|
|
response.body.errorType = errorType;
|
|
}
|
|
|
|
if (error && typeof error === 'object' && 'details' in error && error?.details) {
|
|
try {
|
|
response.body.details = JSON.parse(error.details as unknown as string);
|
|
} catch (e) {
|
|
response.body.details = error.details;
|
|
}
|
|
}
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
public notFound(msg?: string): NotFoundResult {
|
|
return {
|
|
statusCode: 404,
|
|
body: {
|
|
success: false,
|
|
error: msg || 'Resource not found',
|
|
},
|
|
};
|
|
}
|
|
|
|
public internalError<T>(msg?: T): InternalError<T> {
|
|
return {
|
|
statusCode: 500,
|
|
body: {
|
|
success: false,
|
|
error: msg || 'Internal server error',
|
|
},
|
|
};
|
|
}
|
|
|
|
public unavailable<T>(msg?: T): UnavailableResult<T> {
|
|
return {
|
|
statusCode: 503,
|
|
body: {
|
|
success: false,
|
|
error: msg || 'Service Unavailable',
|
|
},
|
|
};
|
|
}
|
|
|
|
public unauthorized<T>(msg?: T): UnauthorizedResult<T> {
|
|
return {
|
|
statusCode: 401,
|
|
body: {
|
|
success: false,
|
|
error: msg || 'unauthorized',
|
|
},
|
|
};
|
|
}
|
|
|
|
public forbidden<T>(msg?: T): ForbiddenResult<T> {
|
|
return {
|
|
statusCode: 403,
|
|
body: {
|
|
success: false,
|
|
// TODO: MAJOR remove 'unauthorized' in favor of 'forbidden'
|
|
// because of reasons beyond my control we were used to send `unauthorized` to 403 cases, to avoid a breaking change we just adapted here
|
|
// but thanks to the semver check tests should break as soon we bump to a new version
|
|
error: msg || (applyBreakingChanges ? 'forbidden' : 'unauthorized'),
|
|
},
|
|
};
|
|
}
|
|
|
|
public tooManyRequests(msg?: string): { statusCode: number; body: Record<string, any> & { success?: boolean } } {
|
|
return {
|
|
statusCode: 429,
|
|
body: {
|
|
success: false,
|
|
error: msg || 'Too many requests',
|
|
},
|
|
};
|
|
}
|
|
|
|
protected getRateLimiter(route: string): { rateLimiter: RateLimiter; options: RateLimiterOptions } {
|
|
return rateLimiterDictionary[route];
|
|
}
|
|
|
|
protected async shouldVerifyRateLimit(route: string, userId?: string): Promise<boolean> {
|
|
return (
|
|
rateLimiterDictionary.hasOwnProperty(route) &&
|
|
settings.get<boolean>('API_Enable_Rate_Limiter') === true &&
|
|
(process.env.NODE_ENV !== 'development' || settings.get<boolean>('API_Enable_Rate_Limiter_Dev') === true) &&
|
|
!(userId && (await hasPermissionAsync(userId, 'api-bypass-rate-limit')))
|
|
);
|
|
}
|
|
|
|
protected async enforceRateLimit(
|
|
objectForRateLimitMatch: RateLimiterOptionsToCheck,
|
|
_: any,
|
|
response: Response,
|
|
userId?: string,
|
|
): Promise<void> {
|
|
if (!(await this.shouldVerifyRateLimit(objectForRateLimitMatch.route, userId))) {
|
|
return;
|
|
}
|
|
|
|
rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.increment(objectForRateLimitMatch);
|
|
const attemptResult = await rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.check(objectForRateLimitMatch);
|
|
const timeToResetAttempsInSeconds = Math.ceil(attemptResult.timeToReset / 1000);
|
|
response.headers.set(
|
|
'X-RateLimit-Limit',
|
|
String(rateLimiterDictionary[objectForRateLimitMatch.route].options.numRequestsAllowed ?? ''),
|
|
);
|
|
response.headers.set('X-RateLimit-Remaining', String(attemptResult.numInvocationsLeft));
|
|
response.headers.set('X-RateLimit-Reset', String(new Date().getTime() + attemptResult.timeToReset));
|
|
|
|
if (!attemptResult.allowed) {
|
|
throw new Meteor.Error(
|
|
'error-too-many-requests',
|
|
`Error, too many requests. Please slow down. You must wait ${timeToResetAttempsInSeconds} seconds before trying this endpoint again.`,
|
|
{
|
|
timeToReset: attemptResult.timeToReset,
|
|
seconds: timeToResetAttempsInSeconds,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
public reloadRoutesToRefreshRateLimiter(): void {
|
|
this._routes.forEach((route) => {
|
|
if (this.shouldAddRateLimitToRoute(route.options)) {
|
|
wrapExceptions(() =>
|
|
this.addRateLimiterRuleForRoutes({
|
|
routes: [route.path],
|
|
rateLimiterOptions: route.options.rateLimiterOptions || defaultRateLimiterOptions,
|
|
endpoints: Object.keys(route.endpoints).filter((endpoint) => endpoint !== 'options'),
|
|
}),
|
|
).catch((error) => {
|
|
console.error(error.message);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
protected addRateLimiterRuleForRoutes({
|
|
routes,
|
|
rateLimiterOptions,
|
|
endpoints,
|
|
}: {
|
|
routes: string[];
|
|
rateLimiterOptions: RateLimiterOptions | boolean;
|
|
endpoints: Record<string, string> | string[];
|
|
}): void {
|
|
if (typeof rateLimiterOptions !== 'object') {
|
|
throw new Meteor.Error('"rateLimiterOptions" must be an object');
|
|
}
|
|
if (!rateLimiterOptions.numRequestsAllowed) {
|
|
throw new Meteor.Error(`You must set "numRequestsAllowed" property in rateLimiter for REST API endpoint: ${routes}`);
|
|
}
|
|
if (!rateLimiterOptions.intervalTimeInMS) {
|
|
throw new Meteor.Error(`You must set "intervalTimeInMS" property in rateLimiter for REST API endpoint: ${routes}`);
|
|
}
|
|
const addRateLimitRuleToEveryRoute = (routes: string[]) => {
|
|
routes.forEach((route) => {
|
|
rateLimiterDictionary[route] = {
|
|
rateLimiter: new RateLimiter(),
|
|
options: rateLimiterOptions,
|
|
};
|
|
const rateLimitRule = {
|
|
IPAddr: (input: any) => input,
|
|
route,
|
|
};
|
|
rateLimiterDictionary[route].rateLimiter.addRule(
|
|
rateLimitRule,
|
|
rateLimiterOptions.numRequestsAllowed as number,
|
|
rateLimiterOptions.intervalTimeInMS as number,
|
|
);
|
|
});
|
|
};
|
|
routes.map((route) => this.namedRoutes(route, endpoints)).map(addRateLimitRuleToEveryRoute);
|
|
}
|
|
|
|
public async processTwoFactor({
|
|
userId,
|
|
request,
|
|
invocation,
|
|
options,
|
|
connection,
|
|
}: {
|
|
userId: string;
|
|
request: Request;
|
|
invocation: { twoFactorChecked?: boolean };
|
|
options?: Options;
|
|
connection: IMethodConnection;
|
|
}): Promise<void> {
|
|
if (options && (!('twoFactorRequired' in options) || !options.twoFactorRequired)) {
|
|
return;
|
|
}
|
|
const code = request.headers.get('x-2fa-code') ? String(request.headers.get('x-2fa-code')) : undefined;
|
|
const method = request.headers.get('x-2fa-method') ? String(request.headers.get('x-2fa-method')) : undefined;
|
|
|
|
await checkCodeForUser({
|
|
user: userId,
|
|
code,
|
|
method,
|
|
options: options && 'twoFactorOptions' in options ? (options as Record<string, any>).twoFactorOptions || {} : {},
|
|
connection,
|
|
});
|
|
|
|
invocation.twoFactorChecked = true;
|
|
}
|
|
|
|
public getFullRouteName(route: string, method: string): string {
|
|
return `/${this.apiPath || ''}/${route}${method}`;
|
|
}
|
|
|
|
protected namedRoutes(route: string, endpoints: Record<string, string> | string[]): string[] {
|
|
const routeActions: string[] = Array.isArray(endpoints) ? endpoints : Object.keys(endpoints);
|
|
|
|
return routeActions.map((action) => this.getFullRouteName(route, action));
|
|
}
|
|
|
|
private registerTypedRoutesLegacy<TSubPathPattern extends string, TOptions extends Options>(
|
|
method: Method,
|
|
subpath: TSubPathPattern,
|
|
options: TOptions,
|
|
): void {
|
|
const { authRequired, validateParams } = options;
|
|
|
|
const opt = {
|
|
authRequired,
|
|
...(validateParams &&
|
|
method.toLowerCase() === 'get' &&
|
|
('GET' in validateParams
|
|
? { query: validateParams.GET }
|
|
: {
|
|
query: validateParams as ValidateFunction<any>,
|
|
})),
|
|
|
|
...(validateParams &&
|
|
method.toLowerCase() === 'post' &&
|
|
('POST' in validateParams ? { query: validateParams.POST } : { body: validateParams as ValidateFunction<any> })),
|
|
|
|
...(validateParams &&
|
|
method.toLowerCase() === 'put' &&
|
|
('PUT' in validateParams ? { query: validateParams.PUT } : { body: validateParams as ValidateFunction<any> })),
|
|
...(validateParams &&
|
|
method.toLowerCase() === 'delete' &&
|
|
('DELETE' in validateParams ? { query: validateParams.DELETE } : { body: validateParams as ValidateFunction<any> })),
|
|
|
|
tags: ['Missing Documentation'],
|
|
response: {
|
|
200: ajv.compile({
|
|
type: 'object',
|
|
properties: {
|
|
success: { type: 'boolean' },
|
|
error: { type: 'string' },
|
|
},
|
|
required: ['success'],
|
|
}),
|
|
},
|
|
};
|
|
|
|
this.registerTypedRoutes(method, subpath, opt);
|
|
}
|
|
|
|
private registerTypedRoutes<
|
|
TSubPathPattern extends string,
|
|
TOptions extends TypedOptions,
|
|
TPathPattern extends `${TBasePath}/${TSubPathPattern}`,
|
|
>(method: MinimalRoute['method'], subpath: TSubPathPattern, options: TOptions): void {
|
|
const path = `/${this.apiPath}/${subpath}`.replaceAll('//', '/') as TPathPattern;
|
|
this.typedRoutes = this.typedRoutes || {};
|
|
this.typedRoutes[path] = this.typedRoutes[subpath] || {};
|
|
const { query, authRequired, response, body, tags, ...rest } = options;
|
|
this.typedRoutes[path][method.toLowerCase()] = {
|
|
...(response && {
|
|
responses: Object.fromEntries(
|
|
Object.entries(response).map(([status, schema]) => [
|
|
status,
|
|
{
|
|
description: '',
|
|
content: {
|
|
'application/json': 'schema' in schema ? { schema: schema.schema } : schema,
|
|
},
|
|
},
|
|
]),
|
|
),
|
|
}),
|
|
...(query && {
|
|
parameters: [
|
|
{
|
|
schema: query.schema,
|
|
in: 'query',
|
|
name: 'query',
|
|
required: true,
|
|
},
|
|
],
|
|
}),
|
|
...(body && {
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': { schema: body.schema },
|
|
},
|
|
},
|
|
}),
|
|
...(authRequired && {
|
|
...rest,
|
|
security: [
|
|
{
|
|
userId: [],
|
|
authToken: [],
|
|
},
|
|
],
|
|
}),
|
|
tags,
|
|
};
|
|
}
|
|
|
|
private method<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
|
|
method: MinimalRoute['method'],
|
|
subpath: TSubPathPattern,
|
|
options: TOptions,
|
|
action: TypedAction<TOptions, TSubPathPattern>,
|
|
): APIClass<
|
|
TBasePath,
|
|
| TOperations
|
|
| Prettify<
|
|
{
|
|
method: Method;
|
|
path: TPathPattern;
|
|
} & Omit<TOptions, 'response'>
|
|
>
|
|
> {
|
|
this.addRoute([subpath], { tags: [], ...options, typed: true }, { [method.toLowerCase()]: { action } } as any);
|
|
this.registerTypedRoutes(method, subpath, options);
|
|
return this;
|
|
}
|
|
|
|
get<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
|
|
subpath: TSubPathPattern,
|
|
options: TOptions,
|
|
action: TypedAction<TOptions, TSubPathPattern>,
|
|
): APIClass<
|
|
TBasePath,
|
|
| TOperations
|
|
| Prettify<
|
|
{
|
|
method: 'GET';
|
|
path: TPathPattern;
|
|
} & TOptions
|
|
>
|
|
> {
|
|
return this.method('GET', subpath, options, action);
|
|
}
|
|
|
|
post<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
|
|
subpath: TSubPathPattern,
|
|
options: TOptions,
|
|
action: TypedAction<TOptions, TSubPathPattern>,
|
|
): APIClass<
|
|
TBasePath,
|
|
| TOperations
|
|
| ({
|
|
method: 'POST';
|
|
path: TPathPattern;
|
|
} & Omit<TOptions, 'response'>)
|
|
| Prettify<
|
|
{
|
|
method: 'POST';
|
|
path: TPathPattern;
|
|
} & TOptions
|
|
>
|
|
> {
|
|
return this.method('POST', subpath, options, action);
|
|
}
|
|
|
|
put<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
|
|
subpath: TSubPathPattern,
|
|
options: TOptions,
|
|
action: TypedAction<TOptions, TSubPathPattern>,
|
|
): APIClass<
|
|
TBasePath,
|
|
| TOperations
|
|
| ({
|
|
method: 'PUT';
|
|
path: TPathPattern;
|
|
} & Omit<TOptions, 'response'>)
|
|
| Prettify<
|
|
{
|
|
method: 'PUT';
|
|
path: TPathPattern;
|
|
} & TOptions
|
|
>
|
|
> {
|
|
return this.method('PUT', subpath, options, action);
|
|
}
|
|
|
|
delete<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
|
|
subpath: TSubPathPattern,
|
|
options: TOptions,
|
|
action: TypedAction<TOptions, TSubPathPattern>,
|
|
): APIClass<
|
|
TBasePath,
|
|
| TOperations
|
|
| ({
|
|
method: 'DELETE';
|
|
path: TPathPattern;
|
|
} & Omit<TOptions, 'response'>)
|
|
| Prettify<
|
|
{
|
|
method: 'DELETE';
|
|
path: TPathPattern;
|
|
} & TOptions
|
|
>
|
|
> {
|
|
return this.method('DELETE', subpath, options, action);
|
|
}
|
|
|
|
/**
|
|
* @deprecated The addRoute method is deprecated. Please use the new route registration methods (get, post, put OR delete).
|
|
*/
|
|
addRoute<TSubPathPattern extends string>(
|
|
subpath: TSubPathPattern,
|
|
operations: Operations<JoinPathPattern<TBasePath, TSubPathPattern>>,
|
|
): void;
|
|
|
|
/**
|
|
* @deprecated The addRoute method is deprecated. Please use the new route registration methods (get, post, put OR delete).
|
|
*/
|
|
addRoute<TSubPathPattern extends string, TPathPattern extends JoinPathPattern<TBasePath, TSubPathPattern>>(
|
|
subpaths: TSubPathPattern[],
|
|
operations: Operations<TPathPattern>,
|
|
): void;
|
|
|
|
/**
|
|
* @deprecated The addRoute method is deprecated. Please use the new route registration methods (get, post, put OR delete).
|
|
*/
|
|
addRoute<TSubPathPattern extends string, TOptions extends Options>(
|
|
subpath: TSubPathPattern,
|
|
options: TOptions,
|
|
operations: Operations<JoinPathPattern<TBasePath, TSubPathPattern>, TOptions>,
|
|
): void;
|
|
|
|
/**
|
|
* @deprecated The addRoute method is deprecated. Please use the new route registration methods (get, post, put OR delete).
|
|
*/
|
|
addRoute<TSubPathPattern extends string, TPathPattern extends JoinPathPattern<TBasePath, TSubPathPattern>, TOptions extends Options>(
|
|
subpaths: TSubPathPattern[],
|
|
options: TOptions,
|
|
operations: Operations<TPathPattern, TOptions>,
|
|
): void;
|
|
|
|
/**
|
|
* @deprecated The addRoute method is deprecated. Please use the new route registration methods (get, post, put OR delete).
|
|
*/
|
|
public addRoute<
|
|
TSubPathPattern extends string,
|
|
TPathPattern extends JoinPathPattern<TBasePath, TSubPathPattern>,
|
|
TOptions extends Options,
|
|
>(subpaths: TSubPathPattern[], options: TOptions, endpoints?: Operations<TPathPattern, TOptions>): void {
|
|
// Note: required if the developer didn't provide options
|
|
if (endpoints === undefined) {
|
|
endpoints = options as unknown as Operations<TPathPattern>;
|
|
options = {} as TOptions;
|
|
}
|
|
|
|
const operations = endpoints;
|
|
|
|
const shouldVerifyPermissions = checkPermissions(options);
|
|
|
|
// Allow for more than one route using the same option and endpoints
|
|
if (!Array.isArray(subpaths)) {
|
|
subpaths = [subpaths];
|
|
}
|
|
if (this.shouldAddRateLimitToRoute(options)) {
|
|
this.addRateLimiterRuleForRoutes({
|
|
routes: subpaths,
|
|
rateLimiterOptions: options.rateLimiterOptions || defaultRateLimiterOptions,
|
|
endpoints: operations as unknown as string[],
|
|
});
|
|
}
|
|
subpaths.forEach((route) => {
|
|
// Note: This is required due to Restivus calling `addRoute` in the constructor of itself
|
|
Object.keys(operations).forEach((method) => {
|
|
const _options = { ...options };
|
|
const { tags = ['Missing Documentation'] } = _options as Record<string, any>;
|
|
|
|
if (typeof operations[method as keyof Operations<TPathPattern, TOptions>] === 'function') {
|
|
(operations as Record<string, any>)[method as string] = {
|
|
action: operations[method as keyof Operations<TPathPattern, TOptions>],
|
|
};
|
|
} else {
|
|
const extraOptions: Record<string, any> = { ...operations[method as keyof Operations<TPathPattern, TOptions>] } as Record<
|
|
string,
|
|
any
|
|
>;
|
|
delete extraOptions.action;
|
|
Object.assign(_options, extraOptions);
|
|
}
|
|
// Add a try/catch for each endpoint
|
|
const originalAction = (operations[method as keyof Operations<TPathPattern, TOptions>] as Record<string, any>).action;
|
|
|
|
if (options.deprecation && shouldBreakInVersion(options.deprecation.version)) {
|
|
throw new Meteor.Error('error-deprecated', `The endpoint ${route} should be removed`);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
const api = this;
|
|
(operations[method as keyof Operations<TPathPattern, TOptions>] as Record<string, any>).action =
|
|
async function _internalRouteActionHandler() {
|
|
this.queryOperations = options.queryOperations;
|
|
this.queryFields = options.queryFields;
|
|
this.logger = logger;
|
|
|
|
if (options.authRequired || options.authOrAnonRequired) {
|
|
const user = await api.authenticatedRoute(this);
|
|
this.user = user!;
|
|
this.userId = this.user?._id;
|
|
const authToken = this.request.headers.get('x-auth-token');
|
|
this.token = (authToken && Accounts._hashLoginToken(String(authToken)))!;
|
|
}
|
|
|
|
const shouldPreventAnonymousRead = !this.user && options.authOrAnonRequired && !settings.get('Accounts_AllowAnonymousRead');
|
|
const shouldPreventUserRead = !this.user && options.authRequired;
|
|
|
|
if (shouldPreventAnonymousRead || shouldPreventUserRead) {
|
|
const result = api.unauthorized('You must be logged in to do this.');
|
|
// compatibility with the old API
|
|
// TODO: MAJOR
|
|
if (!applyBreakingChanges) {
|
|
Object.assign(result.body, {
|
|
status: 'error',
|
|
message: 'You must be logged in to do this.',
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const objectForRateLimitMatch = {
|
|
IPAddr: this.requestIp,
|
|
route: api.getFullRouteName(route, this.request.method.toLowerCase()),
|
|
};
|
|
|
|
let result;
|
|
|
|
const connection = { ...generateConnection(this.requestIp, this.request.headers), token: this.token };
|
|
this.connection = connection;
|
|
|
|
try {
|
|
if (options.deprecation) {
|
|
parseDeprecation(this, options.deprecation);
|
|
}
|
|
|
|
await api.enforceRateLimit(objectForRateLimitMatch, this.request, this.response, this.userId);
|
|
|
|
if (_options.validateParams) {
|
|
const requestMethod = this.request.method as Method;
|
|
const validatorFunc =
|
|
typeof _options.validateParams === 'function' ? _options.validateParams : _options.validateParams[requestMethod];
|
|
|
|
if (validatorFunc && !validatorFunc(requestMethod === 'GET' ? this.queryParams : this.bodyParams)) {
|
|
throw new Meteor.Error('invalid-params', validatorFunc.errors?.map((error: any) => error.message).join('\n '));
|
|
}
|
|
}
|
|
if (shouldVerifyPermissions) {
|
|
if (!this.userId) {
|
|
if (applyBreakingChanges) {
|
|
throw new Meteor.Error('error-unauthorized', 'You must be logged in to do this');
|
|
}
|
|
throw new Meteor.Error('error-unauthorized', 'User does not have the permissions required for this action');
|
|
}
|
|
if (
|
|
!(await checkPermissionsForInvocation(
|
|
this.userId,
|
|
_options.permissionsRequired as PermissionsPayload,
|
|
this.request.method as Method,
|
|
))
|
|
) {
|
|
if (applyBreakingChanges) {
|
|
throw new Meteor.Error('error-forbidden', 'User does not have the permissions required for this action', {
|
|
permissions: _options.permissionsRequired,
|
|
});
|
|
}
|
|
throw new Meteor.Error('error-unauthorized', 'User does not have the permissions required for this action', {
|
|
permissions: _options.permissionsRequired,
|
|
});
|
|
}
|
|
}
|
|
|
|
const invocation = new DDPCommon.MethodInvocation({
|
|
connection,
|
|
isSimulation: false,
|
|
userId: this.userId,
|
|
});
|
|
|
|
Accounts._accountData[connection.id] = {
|
|
connection,
|
|
};
|
|
|
|
Accounts._setAccountData(connection.id, 'loginToken', this.token!);
|
|
|
|
this.userId &&
|
|
(await api.processTwoFactor({
|
|
userId: this.userId,
|
|
request: this.request,
|
|
invocation: invocation as unknown as Record<string, any>,
|
|
options: _options,
|
|
connection: connection as unknown as IMethodConnection,
|
|
}));
|
|
|
|
this.parseJsonQuery = () => api.parseJsonQuery(this);
|
|
|
|
result = (await DDP._CurrentInvocation.withValue(invocation as any, async () => originalAction.apply(this))) || api.success();
|
|
} catch (e: any) {
|
|
result = ((e: any) => {
|
|
switch (e.error) {
|
|
case 'error-too-many-requests':
|
|
return api.tooManyRequests(typeof e === 'string' ? e : e.message);
|
|
case 'unauthorized':
|
|
case 'error-unauthorized':
|
|
if (applyBreakingChanges) {
|
|
return api.unauthorized(typeof e === 'string' ? e : e.message);
|
|
}
|
|
return api.forbidden(typeof e === 'string' ? e : e.message);
|
|
case 'forbidden':
|
|
case 'error-forbidden':
|
|
if (applyBreakingChanges) {
|
|
return api.forbidden(typeof e === 'string' ? e : e.message);
|
|
}
|
|
return api.failure(typeof e === 'string' ? e : e.message, e.error, process.env.TEST_MODE ? e.stack : undefined, e);
|
|
default:
|
|
return api.failure(typeof e === 'string' ? e : e.message, e.error, process.env.TEST_MODE ? e.stack : undefined, e);
|
|
}
|
|
})(e);
|
|
} finally {
|
|
delete Accounts._accountData[connection.id];
|
|
}
|
|
|
|
return result;
|
|
} as InnerAction<any, any, any>;
|
|
// Allow the endpoints to make usage of the logger which respects the user's settings
|
|
(operations[method as keyof Operations<TPathPattern, TOptions>] as Record<string, any>).logger = logger;
|
|
this.router[method.toLowerCase() as 'get' | 'post' | 'put' | 'delete'](
|
|
`/${route}`.replaceAll('//', '/'),
|
|
{ ..._options, tags } as TypedOptions,
|
|
license(_options as TypedOptions, License),
|
|
(operations[method as keyof Operations<TPathPattern, TOptions>] as Record<string, any>).action as any,
|
|
);
|
|
this._routes.push({
|
|
path: route,
|
|
options: _options,
|
|
endpoints: operations[method as keyof Operations<TPathPattern, TOptions>] as unknown as Record<string, string>,
|
|
});
|
|
|
|
this.registerTypedRoutesLegacy(method as Method, route, {
|
|
...options,
|
|
...operations[method as keyof Operations<TPathPattern, TOptions>],
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
protected async authenticatedRoute(routeContext: GenericRouteExecutionContext): Promise<IUser | null> {
|
|
const userId = routeContext.request.headers.get('x-user-id');
|
|
const userToken = routeContext.request.headers.get('x-auth-token');
|
|
|
|
if (userId && userToken) {
|
|
return Users.findOne(
|
|
{
|
|
'services.resume.loginTokens.hashedToken': Accounts._hashLoginToken(userToken),
|
|
'_id': userId,
|
|
},
|
|
{
|
|
projection: getDefaultUserFields(),
|
|
},
|
|
);
|
|
}
|
|
|
|
for (const method of this.authMethods) {
|
|
// eslint-disable-next-line no-await-in-loop -- we want serial execution
|
|
const user = await method(routeContext);
|
|
|
|
if (user) {
|
|
return user;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public updateRateLimiterDictionaryForRoute(route: string, numRequestsAllowed: number, intervalTimeInMS?: number): void {
|
|
if (rateLimiterDictionary[route]) {
|
|
rateLimiterDictionary[route].options.numRequestsAllowed =
|
|
numRequestsAllowed ?? rateLimiterDictionary[route].options.numRequestsAllowed;
|
|
rateLimiterDictionary[route].options.intervalTimeInMS = intervalTimeInMS ?? rateLimiterDictionary[route].options.intervalTimeInMS;
|
|
this.reloadRoutesToRefreshRateLimiter();
|
|
}
|
|
}
|
|
|
|
protected _initAuth(): void {
|
|
const loginCompatibility = (bodyParams: Record<string, any>, request: Request): Record<string, any> => {
|
|
// Grab the username or email that the user is logging in with
|
|
const { user, username, email, password, code: bodyCode } = bodyParams;
|
|
let usernameToLDAPLogin = '';
|
|
|
|
if (password == null) {
|
|
return bodyParams;
|
|
}
|
|
|
|
if (_.without(Object.keys(bodyParams), 'user', 'username', 'email', 'password', 'code').length > 0) {
|
|
return bodyParams;
|
|
}
|
|
|
|
const code = bodyCode || request.headers.get('x-2fa-code');
|
|
|
|
const auth: Record<string, any> = {
|
|
password,
|
|
};
|
|
|
|
if (typeof user === 'string') {
|
|
auth.user = user.includes('@') ? { email: user } : { username: user };
|
|
usernameToLDAPLogin = user;
|
|
} else if (username) {
|
|
auth.user = { username };
|
|
usernameToLDAPLogin = username;
|
|
} else if (email) {
|
|
auth.user = { email };
|
|
usernameToLDAPLogin = email;
|
|
}
|
|
|
|
if (auth.user == null) {
|
|
return bodyParams;
|
|
}
|
|
|
|
if (auth.password.hashed) {
|
|
auth.password = {
|
|
digest: auth.password as { hashed: string },
|
|
algorithm: 'sha-256',
|
|
};
|
|
}
|
|
|
|
const objectToLDAPLogin = {
|
|
ldap: true,
|
|
username: usernameToLDAPLogin,
|
|
ldapPass: auth.password,
|
|
ldapOptions: {},
|
|
};
|
|
if (settings.get<boolean>('LDAP_Enable') && !code) {
|
|
return objectToLDAPLogin;
|
|
}
|
|
|
|
if (code) {
|
|
return {
|
|
totp: {
|
|
code,
|
|
login: settings.get('LDAP_Enable') ? objectToLDAPLogin : auth,
|
|
},
|
|
};
|
|
}
|
|
|
|
return auth;
|
|
};
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
const self = this;
|
|
|
|
(this as APIClass<'/v1'>).addRoute(
|
|
'login',
|
|
{ authRequired: false },
|
|
{
|
|
async post() {
|
|
const request = this.request as unknown as Request;
|
|
const args = loginCompatibility(this.bodyParams, request);
|
|
|
|
const invocation = new DDPCommon.MethodInvocation({
|
|
connection: generateConnection(this.requestIp || '', this.request.headers),
|
|
});
|
|
|
|
try {
|
|
const auth = await DDP._CurrentInvocation.withValue(invocation as any, async () => Meteor.callAsync('login', args));
|
|
this.user = await Users.findOne(
|
|
{
|
|
_id: auth.id,
|
|
},
|
|
{
|
|
projection: getDefaultUserFields(),
|
|
},
|
|
);
|
|
|
|
if (!this.user) {
|
|
return self.unauthorized();
|
|
}
|
|
|
|
this.userId = this.user._id;
|
|
|
|
return self.success({
|
|
status: 'success',
|
|
data: {
|
|
userId: this.userId,
|
|
authToken: auth.token,
|
|
me: await getUserInfo(this.user || ({} as IUser)),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (!(error instanceof Meteor.Error)) {
|
|
return self.internalError();
|
|
}
|
|
|
|
const result = self.unauthorized();
|
|
|
|
if (!applyBreakingChanges) {
|
|
Object.assign(result.body, {
|
|
status: 'error',
|
|
error: error.error,
|
|
details: error.details,
|
|
message: error.reason || error.message,
|
|
...(error.reason === 'User not found' && {
|
|
error: 'Unauthorized',
|
|
message: 'Unauthorized',
|
|
}),
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
},
|
|
},
|
|
);
|
|
|
|
const logout = async function <
|
|
This extends TypedThis<{
|
|
authRequired: true;
|
|
response: any;
|
|
}>,
|
|
>(this: This): Promise<{ status: string; data: { message: string } }> {
|
|
// Remove the given auth token from the user's account
|
|
const hashedToken = this.token;
|
|
const tokenLocation = 'services.resume.loginTokens.hashedToken';
|
|
const index = tokenLocation?.lastIndexOf('.') || 0;
|
|
const tokenPath = tokenLocation?.substring(0, index) || '';
|
|
const tokenFieldName = tokenLocation?.substring(index + 1) || '';
|
|
const tokenToRemove: Record<string, any> = {};
|
|
tokenToRemove[tokenFieldName] = hashedToken;
|
|
const tokenRemovalQuery: Record<string, any> = {};
|
|
tokenRemovalQuery[tokenPath] = tokenToRemove;
|
|
await Users.updateOne(
|
|
{ _id: this.userId },
|
|
{
|
|
$pull: tokenRemovalQuery,
|
|
},
|
|
);
|
|
|
|
// 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(this.userId, { projection: { [tokenPath]: 1 } });
|
|
if (!userTokens) {
|
|
return;
|
|
}
|
|
|
|
const diff = { [tokenPath]: getNestedProp(userTokens, tokenPath) };
|
|
|
|
return { clientAction: 'updated', id: this.userId, diff };
|
|
});
|
|
|
|
const response = {
|
|
status: 'success',
|
|
data: {
|
|
message: "You've been logged out!",
|
|
},
|
|
};
|
|
|
|
return response;
|
|
};
|
|
|
|
/*
|
|
Add a logout endpoint to the API
|
|
After the user is logged out, the onLoggedOut hook is called (see Restfully.configure() for
|
|
adding hook).
|
|
*/
|
|
return (this as APIClass<'/v1'>).addRoute<'/v1/logout', { authRequired: true }>(
|
|
'logout' as any,
|
|
{
|
|
authRequired: true,
|
|
},
|
|
{
|
|
async get() {
|
|
console.warn('Warning: Default logout via GET will be removed in Restivus v1.0. Use POST instead.');
|
|
console.warn(' See https://github.com/kahmali/meteor-restivus/issues/100');
|
|
return logout.call(this as any) as any;
|
|
},
|
|
async post() {
|
|
return logout.call(this as any) as any;
|
|
},
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|