diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index b23d41255c3b..2374a93ef036 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -1,6 +1,7 @@ +import { PasswordlessClient, RegisterOptions, type PasswordlessOptions } from '@passwordlessdev/passwordless-nodejs'; import { Team, api } from '@rocket.chat/core-services'; import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings'; -import { Users, Subscriptions } from '@rocket.chat/models'; +import { PendingUsers, Users, Subscriptions } from '@rocket.chat/models'; import { isUserCreateParamsPOST, isUserSetActiveStatusParamsPOST, @@ -16,6 +17,7 @@ import { isUsersSetPreferencesParamsPOST, isUsersCheckUsernameAvailabilityParamsGET, isUsersSendConfirmationEmailParamsPOST, + isUserRegisterPasswordlessDevParamsPOST, } from '@rocket.chat/rest-typings'; import { Accounts } from 'meteor/accounts-base'; import { Match, check } from 'meteor/check'; @@ -609,6 +611,72 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'users.registerPasswordless', + { + authRequired: false, + rateLimiterOptions: { + numRequestsAllowed: settings.get('Rate_Limiter_Limit_RegisterUser') ?? 1, + intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), + }, + validateParams: isUserRegisterPasswordlessDevParamsPOST, + }, + { + async post() { + if (this.userId) { + return API.v1.failure('Logged in users can not register again.'); + } + + const { username, name, email } = this.bodyParams; + + if (!(await checkUsernameAvailability(username))) { + return API.v1.failure('Username is already in use'); + } + + const id = ( + await PendingUsers.insertOne({ + username, + email, + name, + }) + ).insertedId; + + const options: PasswordlessOptions = { + baseUrl: settings.get('Passwordless_Dev_Url'), + }; + const passwordlessClient = new PasswordlessClient(settings.get('Passwordless_Dev_ApiSecret'), options); + + const registerOptions = new RegisterOptions(); + registerOptions.userId = id; + registerOptions.username = username; + registerOptions.discoverable = true; + + const { token } = await passwordlessClient.createRegisterToken(registerOptions); + if (!token) { + return API.v1.failure('Failed to create register token'); + } + + await PendingUsers.update( + { + _id: id, + }, + { + $set: { + token, + }, + }, + ); + + const user = await PendingUsers.findOneById(id); + if (!user) { + return API.v1.failure('Failed to create user'); + } + + return API.v1.success({ user }); + }, + }, +); + API.v1.addRoute( 'users.resetAvatar', { authRequired: true }, diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 18e4725ffc40..b1285743ac87 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -220,6 +220,7 @@ "@nivo/heatmap": "0.80.0", "@nivo/line": "0.80.0", "@nivo/pie": "0.80.0", + "@passwordlessdev/passwordless-nodejs": "^0.2.0", "@react-aria/color": "^3.0.0-beta.15", "@react-pdf/renderer": "^3.1.12", "@rocket.chat/account-utils": "workspace:^", diff --git a/apps/meteor/server/configuration/passwordless-dev.ts b/apps/meteor/server/configuration/passwordless-dev.ts new file mode 100644 index 000000000000..53f3e0402073 --- /dev/null +++ b/apps/meteor/server/configuration/passwordless-dev.ts @@ -0,0 +1,31 @@ +import { PasswordlessClient, type PasswordlessOptions } from '@passwordlessdev/passwordless-nodejs'; +import { Accounts } from 'meteor/accounts-base'; + +import { settings } from '../../app/settings/server'; + +Accounts.registerLoginHandler('passwordless-dev', async (options: Record) => { + if (!options.passwordless || !settings.get('Passwordless_dev')) { + return; + } + + const { token } = options; + + const apiUrl = settings.get('Passwordless_Dev_Url'); + + if (!token || !apiUrl) { + return; + } + + const clientOptions: PasswordlessOptions = { + baseUrl: settings.get('Passwordless_Dev_Url'), + }; + const passwordlessClient = new PasswordlessClient(settings.get('Passwordless_Dev_ApiSecret'), clientOptions); + + const verifiedUser = await passwordlessClient.verifyToken(token); + + if (verifiedUser) { + console.log('Successfully verified sign-in for user.', verifiedUser); + } else { + console.warn('Sign in failed', verifiedUser); + } +}); diff --git a/apps/meteor/server/models/PendingUsers.ts b/apps/meteor/server/models/PendingUsers.ts new file mode 100644 index 000000000000..cd256c56f766 --- /dev/null +++ b/apps/meteor/server/models/PendingUsers.ts @@ -0,0 +1,6 @@ +import { registerModel } from '@rocket.chat/models'; + +import { db } from '../database/utils'; +import { PendingUsersRaw } from './raw/PendingUsers'; + +registerModel('IPendingUsersModel', new PendingUsersRaw(db)); diff --git a/apps/meteor/server/models/raw/PendingUsers.ts b/apps/meteor/server/models/raw/PendingUsers.ts new file mode 100644 index 000000000000..2729157d9f1c --- /dev/null +++ b/apps/meteor/server/models/raw/PendingUsers.ts @@ -0,0 +1,22 @@ +import type { IPendingUser, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { IPendingUsersModel } from '@rocket.chat/model-typings'; +import type { Db, Collection, IndexDescription } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; + +export class PendingUsersRaw extends BaseRaw implements IPendingUsersModel { + constructor(db: Db, trash?: Collection>) { + super(db, 'pending_users', trash); + } + + protected modelIndexes(): IndexDescription[] { + return [ + { key: { credentialToken: 1 }, unique: false }, + { key: { email: 1 }, unique: false }, + ]; + } + + public async findOneByToken(token: string): Promise { + return this.findOne({ credentialToken: token }); + } +} diff --git a/apps/meteor/server/models/startup.ts b/apps/meteor/server/models/startup.ts index d355d1febd16..c3d42d72d3b5 100644 --- a/apps/meteor/server/models/startup.ts +++ b/apps/meteor/server/models/startup.ts @@ -42,6 +42,7 @@ import './OAuthAccessTokens'; import './OAuthRefreshTokens'; import './OEmbedCache'; import './PbxEvents'; +import './PendingUsers'; import './PushToken'; import './Permissions'; import './MessageReads'; diff --git a/apps/meteor/server/settings/index.ts b/apps/meteor/server/settings/index.ts index aaae0b7ba0bd..e87ef22f1e7c 100644 --- a/apps/meteor/server/settings/index.ts +++ b/apps/meteor/server/settings/index.ts @@ -24,6 +24,7 @@ import { createMobileSettings } from './mobile'; import { createOauthSettings } from './oauth'; import { createOmniSettings } from './omnichannel'; import { createOTRSettings } from './otr'; +import { createPasswordlessSettings } from './passwordless-dev'; import { createPushSettings } from './push'; import { createRateLimitSettings } from './rate'; import { createRetentionSettings } from './retention-policy'; @@ -77,6 +78,7 @@ async function createSettings() { createUserDataSettings(), createWebDavSettings(), createWebRTCSettings(), + createPasswordlessSettings(), ]); } diff --git a/apps/meteor/server/settings/passwordless-dev.ts b/apps/meteor/server/settings/passwordless-dev.ts new file mode 100644 index 000000000000..0daf8859ec7d --- /dev/null +++ b/apps/meteor/server/settings/passwordless-dev.ts @@ -0,0 +1,28 @@ +import { settingsRegistry } from '../../app/settings/server'; + +export const createPasswordlessSettings = () => + settingsRegistry.addGroup('Passwordless_Dev', async function () { + await this.add('Passwordless_Dev_Enable', false, { + type: 'boolean', + }); + + const enableQuery = { + _id: 'Passwordless_Dev_Enable', + value: true, + }; + + await this.add('Passwordless_Dev_Url', 'https://v4.passwordless.dev', { + type: 'string', + enableQuery, + }); + + await this.add('Passwordless_Dev_ApiKey', '', { + type: 'string', + enableQuery, + }); + + await this.add('Passwordless_Dev_ApiSecret', '', { + type: 'string', + enableQuery, + }); + }); diff --git a/packages/core-typings/src/IPendingUser.ts b/packages/core-typings/src/IPendingUser.ts new file mode 100644 index 000000000000..39eb15dd29a3 --- /dev/null +++ b/packages/core-typings/src/IPendingUser.ts @@ -0,0 +1,8 @@ +import type { IRocketChatRecord } from './IRocketChatRecord'; + +export interface IPendingUser extends IRocketChatRecord { + username: string; + name: string; + email: string; + token: string; +} diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index 6411390f0fe9..9f42ebcef836 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -33,6 +33,7 @@ export * from './IPushToken'; export * from './IPushNotificationConfig'; export * from './SlashCommands'; +export * from './IPendingUser'; export * from './IUserDataFile'; export * from './IUserSession'; export * from './IUserStatus'; diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index a1874b144347..9d7e50323f6e 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -43,6 +43,7 @@ export * from './models/IOAuthAccessTokensModel'; export * from './models/IOAuthRefreshTokensModel'; export * from './models/IOEmbedCacheModel'; export * from './models/IPbxEventsModel'; +export * from './models/IPendingUsersModel'; export * from './models/IPushTokenModel'; export * from './models/IPermissionsModel'; export * from './models/IReadReceiptsModel'; diff --git a/packages/model-typings/src/models/IPendingUsersModel.ts b/packages/model-typings/src/models/IPendingUsersModel.ts new file mode 100644 index 000000000000..8f0d4ec455a3 --- /dev/null +++ b/packages/model-typings/src/models/IPendingUsersModel.ts @@ -0,0 +1,10 @@ +import type { IPendingUser } from '@rocket.chat/core-typings'; +import type { InsertOneResult } from 'mongodb'; + +import type { IBaseModel, InsertionModel } from './IBaseModel'; + +export interface IPendingUsersModel extends IBaseModel { + insertOne(user: InsertionModel>): Promise>; + + findOneByToken(token: string): Promise; +} diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 1e83fe72b93e..a06bf6d385d8 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -58,6 +58,7 @@ import type { ITeamMemberModel, ITeamModel, IUploadsModel, + IPendingUsersModel, IUserDataFilesModel, IUsersSessionsModel, IUsersModel, @@ -154,6 +155,7 @@ export const Statistics = proxify('IStatisticsModel'); export const Subscriptions = proxify('ISubscriptionsModel'); export const TeamMember = proxify('ITeamMemberModel'); export const Team = proxify('ITeamModel'); +export const PendingUsers = proxify('IPendingUsersModel'); export const Users = proxify('IUsersModel'); export const Uploads = proxify('IUploadsModel'); export const UserDataFiles = proxify('IUserDataFilesModel'); diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index c47f4be6404d..84773ed8fd21 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -6,6 +6,7 @@ import type { IUser, IPersonalAccessToken, UserStatus, + IPendingUser, } from '@rocket.chat/core-typings'; import Ajv from 'ajv'; @@ -15,6 +16,7 @@ import type { UserCreateParamsPOST } from './users/UserCreateParamsPOST'; import type { UserDeactivateIdleParamsPOST } from './users/UserDeactivateIdleParamsPOST'; import type { UserLogoutParamsPOST } from './users/UserLogoutParamsPOST'; import type { UserRegisterParamsPOST } from './users/UserRegisterParamsPOST'; +import type { UserRegisterPasswordlessDevParamsPOST } from './users/UserRegisterPasswordlessDevParamsPOST'; import type { UserSetActiveStatusParamsPOST } from './users/UserSetActiveStatusParamsPOST'; import type { UsersAutocompleteParamsGET } from './users/UsersAutocompleteParamsGET'; import type { UsersInfoParamsGet } from './users/UsersInfoParamsGet'; @@ -338,6 +340,12 @@ export type UsersEndpoints = { }; }; + '/v1/users.registerPasswordless': { + POST: (params: UserRegisterPasswordlessDevParamsPOST) => { + user: Partial; + }; + }; + '/v1/users.logout': { POST: (params: UserLogoutParamsPOST) => { message: string; @@ -374,6 +382,7 @@ export * from './users/UserSetActiveStatusParamsPOST'; export * from './users/UserDeactivateIdleParamsPOST'; export * from './users/UsersInfoParamsGet'; export * from './users/UserRegisterParamsPOST'; +export * from './users/UserRegisterPasswordlessDevParamsPOST'; export * from './users/UserLogoutParamsPOST'; export * from './users/UsersListTeamsParamsGET'; export * from './users/UsersAutocompleteParamsGET'; diff --git a/packages/rest-typings/src/v1/users/UserRegisterPasswordlessDevParamsPOST.ts b/packages/rest-typings/src/v1/users/UserRegisterPasswordlessDevParamsPOST.ts new file mode 100644 index 000000000000..34a3bcc89e46 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UserRegisterPasswordlessDevParamsPOST.ts @@ -0,0 +1,32 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UserRegisterPasswordlessDevParamsPOST = { + username: string; + name: string; + email: string; +}; + +const UserRegisterPasswordlessDevParamsPostSchema = { + type: 'object', + properties: { + username: { + type: 'string', + }, + name: { + type: 'string', + }, + email: { + type: 'string', + }, + }, + required: ['username', 'name', 'email'], + additionalProperties: false, +}; + +export const isUserRegisterPasswordlessDevParamsPOST = ajv.compile( + UserRegisterPasswordlessDevParamsPostSchema, +); diff --git a/yarn.lock b/yarn.lock index dc01a3898eba..227cf0ee39e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5024,6 +5024,15 @@ __metadata: languageName: node linkType: hard +"@passwordlessdev/passwordless-nodejs@npm:^0.2.0": + version: 0.2.0 + resolution: "@passwordlessdev/passwordless-nodejs@npm:0.2.0" + dependencies: + axios: ^1.4.0 + checksum: 1d5cb47598be6a51e684e36ad2552f393abaab37d1cabab3dd272218fc780eca438f562ed715578e73620f5f1a8ed9f616c6cd90ef754f88a5a41306497fdaef + languageName: node + linkType: hard + "@playwright/test@npm:^1.37.1": version: 1.37.1 resolution: "@playwright/test@npm:1.37.1" @@ -8640,6 +8649,7 @@ __metadata: "@nivo/heatmap": 0.80.0 "@nivo/line": 0.80.0 "@nivo/pie": 0.80.0 + "@passwordlessdev/passwordless-nodejs": ^0.2.0 "@playwright/test": ^1.37.1 "@react-aria/color": ^3.0.0-beta.15 "@react-pdf/renderer": ^3.1.12 @@ -15201,6 +15211,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.4.0": + version: 1.5.1 + resolution: "axios@npm:1.5.1" + dependencies: + follow-redirects: ^1.15.0 + form-data: ^4.0.0 + proxy-from-env: ^1.1.0 + checksum: 4444f06601f4ede154183767863d2b8e472b4a6bfc5253597ed6d21899887e1fd0ee2b3de792ac4f8459fe2e359d2aa07c216e45fd8b9e4e0688a6ebf48a5a8d + languageName: node + linkType: hard + "babel-jest@npm:^29.0.3, babel-jest@npm:^29.5.0, babel-jest@npm:^29.6.1": version: 29.6.1 resolution: "babel-jest@npm:29.6.1" @@ -21776,6 +21797,16 @@ __metadata: languageName: node linkType: hard +"follow-redirects@npm:^1.15.0": + version: 1.15.3 + resolution: "follow-redirects@npm:1.15.3" + peerDependenciesMeta: + debug: + optional: true + checksum: 584da22ec5420c837bd096559ebfb8fe69d82512d5585004e36a3b4a6ef6d5905780e0c74508c7b72f907d1fa2b7bd339e613859e9c304d0dc96af2027fd0231 + languageName: node + linkType: hard + "fontkit@npm:^2.0.2": version: 2.0.2 resolution: "fontkit@npm:2.0.2"