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 = { [K in keyof T]: T[K]; } & unknown; type ExtractValidation = T extends ValidateFunction ? TSchema : never; type UnionToIntersection = (U extends any ? (x: U) => any : never) extends (x: infer I) => any ? I : never; export type ExtractRoutesFromAPI = Prettify< UnionToIntersection< T extends APIClass ? (TOperations extends MinimalRoute ? Prettify> : never) : never > >; type ConvertToRoute = { [K in TRoute['path']]: { [K2 in Extract['method']]: K2 extends 'GET' | 'DELETE' ? ( ...args: [ExtractValidation['query']>] extends [never] ? [params?: never] : [params: ExtractValidation['query']>] ) => ExtractValidation['response'][200]> : K2 extends 'POST' | 'PUT' ? ( params: ExtractValidation['body']>, ) => ExtractValidation< 200 extends keyof Extract['response'] ? Extract['response'][200] : 201 extends keyof Extract['response'] ? Extract['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('API_Enable_Rate_Limiter_Limit_Calls_Default'), intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), }; const rateLimiterDictionary: Record< string, { rateLimiter: RateLimiter; options: RateLimiterOptions; } > = {}; const generateConnection = ( ipAddress: string, httpHeaders: Record, ): { id: string; close: () => void; clientAddress: string; httpHeaders: Record; } => ({ id: Random.id(), // eslint-disable-next-line @typescript-eslint/no-empty-function close() {}, httpHeaders, clientAddress: ipAddress, }); export class APIClass = Record> { public typedRoutes: Record> = {}; protected apiPath?: string; readonly version?: string; private _routes: { path: string; options: Options; endpoints: Record }[] = []; public authMethods: ((routeContext: GenericRouteExecutionContext) => Promise)[]; protected helperMethods: Map 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; 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, ); this.limitedUserFieldsToExclude = { ...this.defaultLimitedUserFieldsToExclude, ...nonPublicFieds, }; } async parseJsonQuery(routeContext: GenericRouteExecutionContext) { return parseJsonQuery(routeContext); } public addAuthMethod(func: (routeContext: GenericRouteExecutionContext) => Promise): 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; public success(result: T): SuccessResult; public success(result: T = {} as T): SuccessResult { if (isObject(result)) { (result as Record).success = true; } const finalResult = { statusCode: 200, body: result, } as SuccessResult; return finalResult as SuccessResult; } public redirect(code: C, result: T): RedirectResult { return { statusCode: code, body: result, }; } public failure(result?: T): FailureResult; public failure( result?: T, errorType?: TErrorType, stack?: TStack, error?: { details: TErrorDetails }, ): FailureResult; public failure( result?: T, errorType?: TErrorType, stack?: TStack, error?: { details: TErrorDetails }, ): FailureResult { const response: { statusCode: 400; body: any & { message?: string; errorType?: string; stack?: string; success?: boolean; details?: Record | 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(msg?: T): InternalError { return { statusCode: 500, body: { success: false, error: msg || 'Internal server error', }, }; } public unavailable(msg?: T): UnavailableResult { return { statusCode: 503, body: { success: false, error: msg || 'Service Unavailable', }, }; } public unauthorized(msg?: T): UnauthorizedResult { return { statusCode: 401, body: { success: false, error: msg || 'unauthorized', }, }; } public forbidden(msg?: T): ForbiddenResult { 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 & { 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 { return ( rateLimiterDictionary.hasOwnProperty(route) && settings.get('API_Enable_Rate_Limiter') === true && (process.env.NODE_ENV !== 'development' || settings.get('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 { 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[]; }): 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 { 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).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[] { const routeActions: string[] = Array.isArray(endpoints) ? endpoints : Object.keys(endpoints); return routeActions.map((action) => this.getFullRouteName(route, action)); } private registerTypedRoutesLegacy( 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, })), ...(validateParams && method.toLowerCase() === 'post' && ('POST' in validateParams ? { query: validateParams.POST } : { body: validateParams as ValidateFunction })), ...(validateParams && method.toLowerCase() === 'put' && ('PUT' in validateParams ? { query: validateParams.PUT } : { body: validateParams as ValidateFunction })), ...(validateParams && method.toLowerCase() === 'delete' && ('DELETE' in validateParams ? { query: validateParams.DELETE } : { body: validateParams as ValidateFunction })), 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( method: MinimalRoute['method'], subpath: TSubPathPattern, options: TOptions, action: TypedAction, ): APIClass< TBasePath, | TOperations | Prettify< { method: Method; path: TPathPattern; } & Omit > > { this.addRoute([subpath], { tags: [], ...options, typed: true }, { [method.toLowerCase()]: { action } } as any); this.registerTypedRoutes(method, subpath, options); return this; } get( subpath: TSubPathPattern, options: TOptions, action: TypedAction, ): APIClass< TBasePath, | TOperations | Prettify< { method: 'GET'; path: TPathPattern; } & TOptions > > { return this.method('GET', subpath, options, action); } post( subpath: TSubPathPattern, options: TOptions, action: TypedAction, ): APIClass< TBasePath, | TOperations | ({ method: 'POST'; path: TPathPattern; } & Omit) | Prettify< { method: 'POST'; path: TPathPattern; } & TOptions > > { return this.method('POST', subpath, options, action); } put( subpath: TSubPathPattern, options: TOptions, action: TypedAction, ): APIClass< TBasePath, | TOperations | ({ method: 'PUT'; path: TPathPattern; } & Omit) | Prettify< { method: 'PUT'; path: TPathPattern; } & TOptions > > { return this.method('PUT', subpath, options, action); } delete( subpath: TSubPathPattern, options: TOptions, action: TypedAction, ): APIClass< TBasePath, | TOperations | ({ method: 'DELETE'; path: TPathPattern; } & Omit) | 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( subpath: TSubPathPattern, operations: Operations>, ): void; /** * @deprecated The addRoute method is deprecated. Please use the new route registration methods (get, post, put OR delete). */ addRoute>( subpaths: TSubPathPattern[], operations: Operations, ): void; /** * @deprecated The addRoute method is deprecated. Please use the new route registration methods (get, post, put OR delete). */ addRoute( subpath: TSubPathPattern, options: TOptions, operations: Operations, TOptions>, ): void; /** * @deprecated The addRoute method is deprecated. Please use the new route registration methods (get, post, put OR delete). */ addRoute, TOptions extends Options>( subpaths: TSubPathPattern[], options: TOptions, operations: Operations, ): 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, TOptions extends Options, >(subpaths: TSubPathPattern[], options: TOptions, endpoints?: Operations): void { // Note: required if the developer didn't provide options if (endpoints === undefined) { endpoints = options as unknown as Operations; 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; if (typeof operations[method as keyof Operations] === 'function') { (operations as Record)[method as string] = { action: operations[method as keyof Operations], }; } else { const extraOptions: Record = { ...operations[method as keyof Operations] } 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] as Record).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] as Record).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, 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; // Allow the endpoints to make usage of the logger which respects the user's settings (operations[method as keyof Operations] as Record).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] as Record).action as any, ); this._routes.push({ path: route, options: _options, endpoints: operations[method as keyof Operations] as unknown as Record, }); this.registerTypedRoutesLegacy(method as Method, route, { ...options, ...operations[method as keyof Operations], }); }); }); } protected async authenticatedRoute(routeContext: GenericRouteExecutionContext): Promise { 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, request: Request): Record => { // 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 = { 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('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 = {}; tokenToRemove[tokenFieldName] = hashedToken; const tokenRemovalQuery: Record = {}; 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; }, }, ); } }