diff --git a/.changeset/flat-poets-cheat.md b/.changeset/flat-poets-cheat.md new file mode 100644 index 0000000000000..2560e2d3ee172 --- /dev/null +++ b/.changeset/flat-poets-cheat.md @@ -0,0 +1,28 @@ +--- +'@rocket.chat/web-ui-registration': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/desktop-api': minor +'@rocket.chat/models': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +## Phishing-Resistant Multi-Factor Authentication + +Introduces a more secure and reliable server-side OAuth authentication flow. + +### What’s New + +- **Improved OAuth login security** + OAuth authentication now happens fully on the server, reducing the risk of token theft, phishing attacks, and client-side credential interception. + +- **Built-in CSRF, state validation, and PKCE protection** + OAuth logins now include stronger protection against CSRF attacks, request tampering, and authorization code interception through secure state validation and PKCE support. + +- **Improved two-step verification with OAuth logins** + Users with email or TOTP two-factor authentication enabled will now be asked to complete 2FA even when signing in with providers like Google, GitHub, GitLab, and others. + +- **Improved mobile & desktop app login** + Mobile and desktop apps now support a smoother and more secure deep-link OAuth login flow. diff --git a/apps/meteor/app/2fa/server/code/EmailCheck.ts b/apps/meteor/app/2fa/server/code/EmailCheck.ts index 5967aa518348b..c52531eaac781 100644 --- a/apps/meteor/app/2fa/server/code/EmailCheck.ts +++ b/apps/meteor/app/2fa/server/code/EmailCheck.ts @@ -10,7 +10,7 @@ import * as Mailer from '../../../mailer/server/api'; import { settings } from '../../../settings/server'; export class EmailCheck implements ICodeCheck { - public readonly name = 'email'; + public readonly name: string = 'email'; private getUserVerifiedEmails(user: IUser): string[] { if (!Array.isArray(user.emails)) { @@ -145,6 +145,6 @@ ${t('If_you_didnt_try_to_login_in_your_account_please_ignore_this_email')} public async maxFaildedAttemtpsReached(user: IUser) { const maxAttempts = settings.get('Accounts_TwoFactorAuthentication_Max_Invalid_Email_Code_Attempts'); - return (await Users.maxInvalidEmailCodeAttemptsReached(user._id, maxAttempts)) as boolean; + return Users.maxInvalidEmailCodeAttemptsReached(user._id, maxAttempts); } } diff --git a/apps/meteor/app/2fa/server/code/EmailCheckForOAuth.ts b/apps/meteor/app/2fa/server/code/EmailCheckForOAuth.ts new file mode 100644 index 0000000000000..68c3e8a05506d --- /dev/null +++ b/apps/meteor/app/2fa/server/code/EmailCheckForOAuth.ts @@ -0,0 +1,37 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { TwoFactorChallenges } from '@rocket.chat/models'; + +import { EmailCheck } from './EmailCheck'; + +export class EmailCheckForOAuth extends EmailCheck { + public override readonly name = 'email-oauth'; + + public readonly method = 'email'; + + public async sendTwoFactorChallenge(user: IUser): Promise { + const challengeId = await TwoFactorChallenges.createTwoFactorChallenge(user._id, 'email'); + await this.sendEmailCode(user); + return challengeId; + } + + public async verifyEmailTwoFactorChallenge(user: IUser, challengeId: string, code: string): Promise { + const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId); + if (!challenge) { + return false; + } + + if (challenge.expireAt && challenge.expireAt < new Date()) { + throw new Meteor.Error('error-challenge-expired', 'challenge expired'); + } + + const isCodeValid = await this.verify(user, code); + + if (!isCodeValid) { + return false; + } + + await TwoFactorChallenges.removeByPendingChallengeId(challengeId); + + return true; + } +} diff --git a/apps/meteor/app/2fa/server/code/TOTPCheck.ts b/apps/meteor/app/2fa/server/code/TOTPCheck.ts index 236016883f89e..ae9502569457f 100644 --- a/apps/meteor/app/2fa/server/code/TOTPCheck.ts +++ b/apps/meteor/app/2fa/server/code/TOTPCheck.ts @@ -5,7 +5,7 @@ import { settings } from '../../../settings/server'; import { TOTP } from '../lib/totp'; export class TOTPCheck implements ICodeCheck { - public readonly name = 'totp'; + public readonly name: string = 'totp'; public isEnabled(user: IUser): boolean { if (!settings.get('Accounts_TwoFactorAuthentication_By_TOTP_Enabled')) { diff --git a/apps/meteor/app/2fa/server/code/TOTPCheckForOAuth.ts b/apps/meteor/app/2fa/server/code/TOTPCheckForOAuth.ts new file mode 100644 index 0000000000000..02e8267fb59eb --- /dev/null +++ b/apps/meteor/app/2fa/server/code/TOTPCheckForOAuth.ts @@ -0,0 +1,35 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { TwoFactorChallenges } from '@rocket.chat/models'; + +import { TOTPCheck } from './TOTPCheck'; + +export class TOTPCheckForOAuth extends TOTPCheck { + public override readonly name = 'totp-oauth'; + + public readonly method = 'totp'; + + public async sendTwoFactorChallenge(user: IUser): Promise { + return TwoFactorChallenges.createTwoFactorChallenge(user._id, 'totp'); + } + + public async verifyEmailTwoFactorChallenge(user: IUser, challengeId: string, code: string): Promise { + const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId); + if (!challenge) { + return false; + } + + if (challenge.expireAt && challenge.expireAt < new Date()) { + throw new Meteor.Error('error-challenge-expired', 'challenge expired'); + } + + const isCodeValid = await this.verify(user, code); + + if (!isCodeValid) { + return false; + } + + await TwoFactorChallenges.removeByPendingChallengeId(challengeId); + + return true; + } +} diff --git a/apps/meteor/app/2fa/server/code/index.ts b/apps/meteor/app/2fa/server/code/index.ts index 8242b1c66185e..0094b25bd321d 100644 --- a/apps/meteor/app/2fa/server/code/index.ts +++ b/apps/meteor/app/2fa/server/code/index.ts @@ -63,8 +63,8 @@ function getFingerprintFromConnection(connection: IMethodConnection): string { return crypto.createHash('md5').update(data).digest('hex'); } -function getRememberDate(from: Date = new Date()): Date | undefined { - const rememberFor = parseInt(settings.get('Accounts_TwoFactorAuthentication_RememberFor') as string, 10); +export function getRememberDate(from: Date = new Date()): Date | undefined { + const rememberFor = settings.get('Accounts_TwoFactorAuthentication_RememberFor'); if (rememberFor <= 0) { return; @@ -118,6 +118,20 @@ function isAuthorizedForToken(connection: IMethodConnection, user: IUser, option return true; } +export async function rememberAuthorizationByToken(token: string, userId: IUser['_id'], connection: IMethodConnection): Promise { + const user = await Users.findOneByIdAndLoginHashedToken(userId, token, { projection: { _id: 1, services: 1 } }); + if (!user) { + throw new Meteor.Error('error-user-not-found', 'user not found'); + } + + const expires = getRememberDate(); + if (!expires) { + return; + } + + await Users.setTwoFactorAuthorizationHashAndUntilForUserIdAndToken(user._id, token, getFingerprintFromConnection(connection), expires); +} + async function rememberAuthorization(connection: IMethodConnection, user: IUser): Promise { const currentToken = Accounts._getLoginToken(connection.id); @@ -146,7 +160,7 @@ interface ICheckCodeForUser { connection?: IMethodConnection; } -const getSecondFactorMethod = (user: IUser, method: string | undefined, options: ITwoFactorOptions): ICodeCheck | undefined => { +export const getSecondFactorMethod = (user: IUser, method: string | undefined, options: ITwoFactorOptions): ICodeCheck | undefined => { // try first getting one of the available methods or the one that was already provided const selectedMethod = getMethodByNameOrFirstActiveForUser(user, method); if (selectedMethod) { @@ -174,7 +188,6 @@ export async function checkCodeForUser({ user, code, method, options = {}, conne } let existingUser: IUser | null; - if (typeof user === 'string') { existingUser = await getUserForCheck(user); } else { diff --git a/apps/meteor/app/api/server/ApiClass.ts b/apps/meteor/app/api/server/ApiClass.ts index d51726049e0dd..62a2d7c0601c8 100644 --- a/apps/meteor/app/api/server/ApiClass.ts +++ b/apps/meteor/app/api/server/ApiClass.ts @@ -144,7 +144,7 @@ const rateLimiterDictionary: Record< } > = {}; -const generateConnection = ( +export const generateConnection = ( ipAddress: string, httpHeaders: Record, ): { diff --git a/apps/meteor/app/api/server/index.ts b/apps/meteor/app/api/server/index.ts index 5a6a6f06cbbab..c53c56d797cc5 100644 --- a/apps/meteor/app/api/server/index.ts +++ b/apps/meteor/app/api/server/index.ts @@ -45,7 +45,7 @@ import './v1/mailer'; import './v1/teams'; import './v1/moderation'; import './v1/uploads'; - +import './v1/twoFactorChallenges'; // This has to come last so all endpoints are registered before generating the OpenAPI documentation import './default/openApi'; diff --git a/apps/meteor/app/api/server/v1/twoFactorChallenges.ts b/apps/meteor/app/api/server/v1/twoFactorChallenges.ts new file mode 100644 index 0000000000000..fd48e22ad7123 --- /dev/null +++ b/apps/meteor/app/api/server/v1/twoFactorChallenges.ts @@ -0,0 +1,112 @@ +import type { IMethodConnection } from '@rocket.chat/core-typings'; +import { TwoFactorChallenges } from '@rocket.chat/models'; +import { isTwoFactorChallengesSendEmailCodeParamsPOST, isTwoFactorChallengesVerifyChallengeParamsPOST } from '@rocket.chat/rest-typings'; +import { Accounts } from 'meteor/accounts-base'; + +import { emailCheckForOAuth, getTwoFAMethodForOAuth } from '../../../../server/lib/oauth/twoFactorAuth'; +import { getUserForCheck, rememberAuthorizationByToken } from '../../../2fa/server/code'; +import { generateConnection } from '../ApiClass'; +import { API } from '../api'; + +API.v1.addRoute( + 'twoFactorChallenges.sendEmailCode', + { validateParams: isTwoFactorChallengesSendEmailCodeParamsPOST, rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 5 } }, + { + async post() { + const { challengeId } = this.bodyParams; + + if (!challengeId) { + throw new Meteor.Error('error-parameter-required', 'challengeId is required'); + } + + const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId); + + if (!challenge) { + throw new Meteor.Error('error-challenge-not-found', 'challenge not found'); + } + + if (challenge.expireAt && challenge.expireAt < new Date()) { + throw new Meteor.Error('error-challenge-expired', 'challenge expired'); + } + + if (challenge.method !== 'email') { + throw new Meteor.Error('error-invalid-challenge-method', 'invalid challenge method'); + } + + const { userId } = challenge; + + const user = await getUserForCheck(userId); + + if (!user) { + throw new Meteor.Error('error-user-not-found', 'user not found'); + } + + await emailCheckForOAuth.sendEmailCode(user); + + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'twoFactorChallenges.verifyChallenge', + { + validateParams: isTwoFactorChallengesVerifyChallengeParamsPOST, + rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 5 }, + }, + { + async post() { + const { challengeId, code } = this.bodyParams; + + if (!challengeId || !code) { + throw new Meteor.Error('error-parameter-required', 'challengeId and code are required'); + } + + const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId); + + if (!challenge) { + throw new Meteor.Error('error-challenge-not-found', 'challenge not found'); + } + + const { userId } = challenge; + + const user = await getUserForCheck(userId); + + if (!user) { + throw new Meteor.Error('error-user-not-found', 'user not found'); + } + + const twoFAMethod = getTwoFAMethodForOAuth(challenge.method); + + const isCodeValid = await twoFAMethod.verifyEmailTwoFactorChallenge(user, challengeId, code); + + if (!isCodeValid) { + const tooManyAttempts = await twoFAMethod.maxFaildedAttemtpsReached(user); + if (tooManyAttempts) { + await TwoFactorChallenges.removeByPendingChallengeId(challengeId); + throw new Meteor.Error('totp-max-attempts', 'TOTP Maximun Failed Attempts Reached'); + } + return API.v1.failure('error-invalid-code', 'Invalid code'); + } + + const stampedToken = Accounts._generateStampedLoginToken(); + + await Accounts._insertLoginToken(user._id, stampedToken); + + const hashedToken = Accounts._hashLoginToken(stampedToken.token); + + const connection = { + ...generateConnection(this.requestIp, this.request.headers), + token: hashedToken, + } as unknown as IMethodConnection; + + // remember the 2FA authorization for the next requests + await rememberAuthorizationByToken(hashedToken, user._id, connection); + + return API.v1.success({ + loginToken: stampedToken.token, + userId: user._id, + }); + }, + }, +); diff --git a/apps/meteor/app/apple/lib/handleIdentityToken.ts b/apps/meteor/app/apple/lib/handleIdentityToken.ts index f3ab1c8f9e66f..861aa42707077 100644 --- a/apps/meteor/app/apple/lib/handleIdentityToken.ts +++ b/apps/meteor/app/apple/lib/handleIdentityToken.ts @@ -27,7 +27,7 @@ async function isValidAppleJWT(identityToken: string, header: any): Promise { +export async function handleIdentityToken(identityToken: string): Promise> { const decodedToken = KJUR.jws.JWS.parse(identityToken); if (!(await isValidAppleJWT(identityToken, decodedToken.headerObj))) { @@ -38,15 +38,14 @@ export async function handleIdentityToken(identityToken: string): Promise<{ id: throw new Error('identityToken does not have a payload'); } - const { iss, sub, email } = decodedToken.payloadObj as any; + const { iss, sub } = decodedToken.payloadObj as any; if (!iss) { throw new Error('Insufficient data in auth response token'); } const serviceData = { id: sub, - email, - name: '', + ...decodedToken.payloadObj, }; return serviceData; diff --git a/apps/meteor/app/apple/server/appleOauthRegisterService.ts b/apps/meteor/app/apple/server/appleOauthRegisterService.ts index 7cb748c7ab917..429909b94901b 100644 --- a/apps/meteor/app/apple/server/appleOauthRegisterService.ts +++ b/apps/meteor/app/apple/server/appleOauthRegisterService.ts @@ -1,9 +1,18 @@ -import { KJUR } from 'jsrsasign'; +import { MeteorError } from '@rocket.chat/core-services'; +import { Users } from '@rocket.chat/models'; +import express from 'express'; +import { Accounts } from 'meteor/accounts-base'; import { ServiceConfiguration } from 'meteor/service-configuration'; +import passport from 'passport'; +import { Strategy as AppleStrategy } from 'passport-apple'; +import type { Profile } from 'passport-apple'; import { AppleCustomOAuth } from './AppleCustomOAuth'; +import { oAuthRouter } from '../../../server/configuration/configurePassport'; +import { passportOAuthCallback } from '../../../server/lib/oauth/passportOAuthCallback'; import { settings } from '../../settings/server'; import { config } from '../lib/config'; +import { handleIdentityToken } from '../lib/handleIdentityToken'; new AppleCustomOAuth('apple', config); @@ -17,6 +26,7 @@ settings.watchMultiple( ], async ([enabled, clientId, serverSecret, iss, kid]) => { if (!enabled) { + passport.unuse('apple'); return ServiceConfiguration.configurations.removeAsync({ service: 'apple', }); @@ -38,43 +48,96 @@ settings.watchMultiple( return; } - const HEADER = { - kid, - alg: 'ES256', - }; - - const now = new Date(); - const exp = new Date(); - exp.setMonth(exp.getMonth() + 5); // from Apple docs expiration time must no be greater than 6 months - - const secret = KJUR.jws.JWS.sign( - null, - HEADER, - { - iss, - iat: Math.floor(now.getTime() / 1000), - exp: Math.floor(exp.getTime() / 1000), - aud: 'https://appleid.apple.com', - sub: clientId, - }, - serverSecret as string, - ); + passport.unuse('apple'); - await ServiceConfiguration.configurations.upsertAsync( - { - service: 'apple', - }, - { - $set: { - showButton: true, - secret, - enabled: settings.get('Accounts_OAuth_Apple'), - loginStyle: 'popup', - clientId: clientId as string, - buttonColor: '#000', - buttonLabelColor: '#FFF', + passport.use( + 'apple', + + new AppleStrategy( + { + clientID: settings.get('Accounts_OAuth_Apple_id'), + teamID: settings.get('Accounts_OAuth_Apple_iss'), + keyID: settings.get('Accounts_OAuth_Apple_kid'), + privateKeyString: settings.get('Accounts_OAuth_Apple_secretKey').replace(/\\n/g, '\n'), + callbackURL: `${settings.get('Site_Url')}/_oauth/apple`, + scope: ['name', 'email'], + passReqToCallback: false, + state: false, }, + async (accessToken: string, refreshToken: string, idToken: string, profile: Profile, done) => { + try { + const serviceData = await handleIdentityToken(idToken); + if (profile?.name) { + serviceData.name = `${profile.name.firstName}${profile.name.middleName ? ` ${profile.name.middleName}` : ''}${ + profile.name.lastName ? ` ${profile.name.lastName}` : '' + }`; + } + + if (!serviceData.email && profile?.email) { + serviceData.email = profile.email; + } + + const user = await Accounts.updateOrCreateUserFromExternalService( + 'apple', + { + accessToken, + refreshToken, + ...serviceData, + }, + {}, + ); + + if (!user?.userId || typeof user?.userId !== 'string') { + return done(new Error('User not found')); + } + + const userFromDB = await Users.findOneById(user.userId); + + if (!userFromDB) { + return done(new Error('User not found')); + } + + return done(null, userFromDB); + } catch (error: any) { + done(error); + return { + type: 'apple', + error: new MeteorError(Accounts.LoginCancelledError.numericError, error.message), + }; + } + }, + ), + ); + + const callbackHandler = [ + express.urlencoded({ extended: true }), + passport.authenticate('apple', { failWithError: true, session: true, keepSessionInfo: true }), + passportOAuthCallback(settings.get('Site_Url')), + ]; + + oAuthRouter.get( + '/oauth/apple', + (req, _res, next) => { + const { loginClient } = req.query; + if (loginClient === 'mobile' || loginClient === 'desktop') { + req.session.loginClient = loginClient; + req.session.save(() => { + next(); + }); + } else { + //delete stale value from previous sessions if any + delete req.session.loginClient; + next(); + } }, + passport.authenticate('apple', { + scope: ['name', 'email'], + }), ); + + oAuthRouter + .route('/_oauth/apple') + .post(...callbackHandler) + .get(...callbackHandler); }, ); diff --git a/apps/meteor/app/apple/server/loginHandler.ts b/apps/meteor/app/apple/server/loginHandler.ts index 18ac7ddd75268..dcb17fa06aff9 100644 --- a/apps/meteor/app/apple/server/loginHandler.ts +++ b/apps/meteor/app/apple/server/loginHandler.ts @@ -29,10 +29,10 @@ Accounts.registerLoginHandler('apple', async (loginRequest) => { profile.name = `${givenName} ${familyName}`; } - const result = Accounts.updateOrCreateUserFromExternalService('apple', serviceData, { profile }); + const result = await Accounts.updateOrCreateUserFromExternalService('apple', serviceData, { profile }); // Ensure processing succeeded - if (result === undefined || result.userId === undefined) { + if (result?.userId === undefined) { return { type: 'apple', error: new Meteor.Error(Accounts.LoginCancelledError.numericError, 'User creation failed from Apple response token'), diff --git a/apps/meteor/app/custom-oauth/server/customOAuth.ts b/apps/meteor/app/custom-oauth/server/customOAuth.ts new file mode 100644 index 0000000000000..13c4d4d6ca373 --- /dev/null +++ b/apps/meteor/app/custom-oauth/server/customOAuth.ts @@ -0,0 +1,393 @@ +import { LDAP } from '@rocket.chat/core-services'; +import type { IUser, OAuthConfiguration } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { Users } from '@rocket.chat/models'; +import { isAbsoluteURL } from '@rocket.chat/tools'; +import { Accounts } from 'meteor/accounts-base'; +import type { DoneCallback } from 'passport'; +import type { VerifyFunction, StrategyOptions } from 'passport-oauth2'; +import { Strategy } from 'passport-oauth2'; + +import { normalizers, fromTemplate, renameInvalidProperties } from './transform_helpers'; +import { client } from '../../../server/database/utils'; +import { callbacks } from '../../../server/lib/callbacks'; +import { saveUserIdentity } from '../../lib/server/functions/saveUserIdentity'; +import { notifyOnUserChange } from '../../lib/server/lib/notifyListener'; +import { settings } from '../../settings/server/cached'; + +const logger = new Logger('CustomOAuth'); +const BeforeUpdateOrCreateUserFromExternalService: ((serviceName: string, serviceData: Record) => Promise)[] = []; + +export class CustomOAuthStrategy extends Strategy { + options: StrategyOptions; + + config: OAuthConfiguration & { clientSecret: string }; + + identityPath: string; + + emailPath: string; + + tokenSentVia: string; + + identityTokenSentVia: string; + + keyField: string; + + usernameField: string; + + emailField: string; + + nameField: string; + + avatarField: string; + + mergeUsers: boolean; + + mergeUsersDistinctServices: boolean; + + rolesClaim: string; + + accessTokenParam: string; + + serverURL: string; + + tokenPath: string; + + constructor(name: string, config: OAuthConfiguration & { clientSecret: string }, verify: VerifyFunction) { + if (!config.serverURL || typeof config.serverURL !== 'string') { + throw new Meteor.Error('customOAuth: serverURL is required and must be String'); + } + + const options: StrategyOptions = { + authorizationURL: config.serverURL + config.authorizePath, + tokenURL: config.serverURL + config.tokenPath, + clientID: config.clientId, + clientSecret: config.clientSecret, + callbackURL: `${settings.get('Site_Url')}/_oauth/${name}`, + state: true, + pkce: config.pkce ?? true, + scope: config.scope, + }; + + super(options, verify); + + this.serverURL = config.serverURL; + this.tokenPath = config.tokenPath || '/oauth/token'; + this.identityPath = config.identityPath || '/me'; + this.tokenSentVia = config.tokenSentVia; + this.identityTokenSentVia = config.identityTokenSentVia; + this.keyField = config.keyField; + this.usernameField = config.usernameField?.trim(); + this.emailField = config.emailField?.trim(); + this.nameField = config.nameField?.trim(); + this.avatarField = config.avatarField?.trim(); + this.mergeUsers = !!config.mergeUsers; + this.mergeUsersDistinctServices = !!config.mergeUsersDistinctServices; + this.rolesClaim = config.rolesClaim || 'roles'; + this.accessTokenParam = config.accessTokenParam || 'access_token'; + + if (this.identityTokenSentVia == null || this.identityTokenSentVia === 'default') { + this.identityTokenSentVia = this.tokenSentVia; + } + + if (!isAbsoluteURL(this.tokenPath)) { + this.tokenPath = this.serverURL + this.tokenPath; + } + + if (!isAbsoluteURL(this.identityPath)) { + this.identityPath = this.serverURL + this.identityPath; + } + + if (this.emailPath && !isAbsoluteURL(this.emailPath)) { + this.emailPath = this.serverURL + this.emailPath; + } + + if (config.tokenSentVia === 'header') { + this._oauth2.useAuthorizationHeaderforGET(true); + } + + if (config.accessTokenParam && config.accessTokenParam !== 'access_token') { + this._oauth2.setAccessTokenName(config.accessTokenParam); + } + + if (config.addAutopublishFields && typeof config.addAutopublishFields === 'object') { + Accounts.addAutopublishFields(config.addAutopublishFields); + } + + this.name = name; + this.options = options; + this.config = config; + + this.addHookToProcessUser(); + } + + getUsername(data: Record) { + try { + const value = fromTemplate(this.usernameField, data); + + if (!value) { + logger.debug({ msg: 'Username field not found in data', usernameField: this.usernameField, data }); + throw new Meteor.Error('field_not_found', `Username field "${this.usernameField}" not found in data`); + } + + return value as string; + } catch (error) { + throw new Error('CustomOAuth: Failed to extract username', { cause: error }); + } + } + + getEmail(data: Record) { + try { + const value = fromTemplate(this.emailField, data); + + if (!value) { + logger.debug({ msg: 'Email field not found in data', emailField: this.emailField, data }); + throw new Meteor.Error('field_not_found', `Email field "${this.emailField}" not found in data`); + } + return value as string; + } catch (error) { + throw new Error('CustomOAuth: Failed to extract email', { cause: error }); + } + } + + getCustomName(data: Record) { + try { + const value = fromTemplate(this.nameField, data); + + if (!value) { + return this.getName(data); + } + + return value as string; + } catch (error) { + throw new Error('CustomOAuth: Failed to extract custom name', { cause: error }); + } + } + + getAvatarUrl(data: Record) { + try { + const value = fromTemplate(this.avatarField, data); + + if (!value) { + logger.debug({ msg: 'Avatar field not found in data', avatarField: this.avatarField, data }); + } + return value as string; + } catch (error) { + throw new Error('CustomOAuth: Failed to extract avatar url', { cause: error }); + } + } + + getName(identity: Record): string { + const name = (identity.name || + identity.username || + identity.nickname || + identity.CharacterName || + identity.userName || + identity.preferred_username || + identity.user?.name) as string; + return name; + } + + normalizeIdentity(identity: Record) { + if (identity) { + for (const normalizer of Object.values(normalizers)) { + const result = normalizer(identity); + if (result) { + identity = result; + } + } + } + + if (this.usernameField) { + identity.username = this.getUsername(identity); + } + + if (this.emailField) { + identity.email = this.getEmail(identity); + } + + if (this.avatarField) { + identity.avatarUrl = this.getAvatarUrl(identity); + } + + if (this.nameField) { + identity.name = this.getCustomName(identity); + } else { + identity.name = this.getName(identity); + } + + return renameInvalidProperties(identity); + } + + override userProfile(accessToken: string, done: DoneCallback) { + if (!this.identityPath) { + return done(new Error(`identityPath is required for ${this.name} custom oauth`)); + } + + this._oauth2.get(this.identityPath, accessToken, (err, body, res) => { + if (err) { + return done(err); + } + + if ((res && res.statusCode !== 200) || !body) { + return done(new Error(`Failed to fetch identity from ${this.name} at ${this.identityPath}}`)); + } + + try { + const result = JSON.parse(typeof body === 'string' ? body : body.toString()); + const normalizedIdentity = this.normalizeIdentity(result); + //Nextcloud URL needed on addWebdavServer + normalizedIdentity.serverURL = this.serverURL; + return done(null, normalizedIdentity); + } catch (e) { + return done(new Error(`Failed to parse identity from ${this.name} at ${this.identityPath}. ${e}`)); + } + }); + } + + addHookToProcessUser() { + BeforeUpdateOrCreateUserFromExternalService.push(async (serviceName, serviceData) => { + if (serviceName !== this.name) { + return; + } + + if (serviceData.username) { + let user: IUser | null = null; + + if (this.keyField === 'username') { + user = this.mergeUsersDistinctServices + ? await Users.findOneByUsernameIgnoringCase(serviceData.username) + : await Users.findOneByUsernameAndServiceNameIgnoringCase(serviceData.username, serviceData.id, serviceName); + } else if (this.keyField === 'email') { + user = this.mergeUsersDistinctServices + ? await Users.findOneByEmailAddress(serviceData.email) + : await Users.findOneByEmailAddressAndServiceNameIgnoringCase(serviceData.email, serviceData.id, serviceName); + } + + if (!user) { + return; + } + + await callbacks.run('afterProcessOAuthUser', { serviceName, serviceData, user }); + // User already created or merged and has identical name as before + if ( + user.services?.[serviceName as keyof NonNullable] && + user.services[serviceName as keyof NonNullable].id === serviceData.id && + user.name === serviceData.name && + (this.keyField === 'email' || !serviceData.email || user.emails?.find(({ address }) => address === serviceData.email)) + ) { + return; + } + + if (this.mergeUsers !== true) { + throw new Meteor.Error('CustomOAuth', `User with username ${user.username} already exists`); + } + + const serviceIdKey = `services.${serviceName}.id`; + const successCallbacks = [ + async () => { + const updatedUser = await Users.findOneById(user._id, { projection: { name: 1, emails: 1, [serviceIdKey]: 1 } }); + if (updatedUser) { + const { _id, ...diff } = updatedUser; + void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff }); + } + }, + ]; + + const session = client.startSession(); + try { + // Extend the session to match the ExtendedSession type expected by saveUserIdentity + Object.assign(session, { + onceSuccesfulCommit: (cb: () => Promise) => { + successCallbacks.push(cb); + }, + }); + + session.startTransaction(); + + const updater = Users.getUpdater(); + + if (this.keyField === 'username' && serviceData.email) { + updater.set('emails', [{ address: serviceData.email, verified: true }]); + } + + updater.set(serviceIdKey as keyof IUser['services'], serviceData.id); + + await saveUserIdentity({ + _id: user._id, + name: serviceData.name, + updater, + session, + updateUsernameInBackground: true, + // Username needs to be included otherwise the name won't be updated in some collections + username: user.username, + }); + await Users.updateFromUpdater({ _id: user._id }, updater, { session }); + + await session.commitTransaction(); + } catch (e) { + await session.abortTransaction(); + throw e; + } finally { + await session.endSession(); + } + + void Promise.allSettled(successCallbacks.map((cb) => cb())); + } + }); + + Accounts.validateNewUser((user: IUser & { email: string }) => { + if (!user.services?.[this.name as keyof NonNullable]?.id) { + return true; + } + + if (this.usernameField) { + user.username = user.services[this.name as keyof NonNullable].username; + } + + if (this.emailField) { + user.email = user.services[this.name as keyof NonNullable].email; + } + + if (this.nameField) { + user.name = user.services[this.name as keyof NonNullable].name; + } + + return true; + }); + } +} + +const { updateOrCreateUserFromExternalService } = Accounts; + +Accounts.updateOrCreateUserFromExternalService = async function (...args) { + for (const hook of BeforeUpdateOrCreateUserFromExternalService) { + await hook.apply(this, args as unknown as [string, Record]); + } + + const [serviceName, serviceData] = args; + + const user = await updateOrCreateUserFromExternalService.apply(this, args); + + if (!user?.userId) { + return undefined; + } + + const fullUser = await Users.findOneById(user.userId as string); + + if (!fullUser) { + return undefined; + } + + if (settings.get('LDAP_Update_Data_On_OAuth_Login') && fullUser.username) { + await LDAP.loginAuthenticatedUserRequest(fullUser.username); + } + + await callbacks.run('afterValidateNewOAuthUser', { + identity: serviceData, + serviceName, + user: fullUser, + }); + + return user; +}; diff --git a/apps/meteor/app/custom-oauth/server/custom_oauth_server.js b/apps/meteor/app/custom-oauth/server/custom_oauth_server.js index 40495449e8b22..aea366ce4e96c 100644 --- a/apps/meteor/app/custom-oauth/server/custom_oauth_server.js +++ b/apps/meteor/app/custom-oauth/server/custom_oauth_server.js @@ -23,6 +23,9 @@ const logger = new Logger('CustomOAuth'); const Services = {}; const BeforeUpdateOrCreateUserFromExternalService = []; +/** + * @deprecated in favor of new Passport OAuth implementation. + */ export class CustomOAuth { constructor(name, options) { logger.debug({ msg: 'Init CustomOAuth', name, options }); diff --git a/apps/meteor/app/dolphin/server/lib.ts b/apps/meteor/app/dolphin/server/lib.ts index 826a277b81e4e..77622fe43edab 100644 --- a/apps/meteor/app/dolphin/server/lib.ts +++ b/apps/meteor/app/dolphin/server/lib.ts @@ -1,13 +1,15 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import type { IUser, OAuthConfiguration } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { ServiceConfiguration } from 'meteor/service-configuration'; +import passport from 'passport'; +import _ from 'underscore'; import { callbacks } from '../../../server/lib/callbacks'; import { beforeCreateUserCallback } from '../../../server/lib/callbacks/beforeCreateUserCallback'; -import { CustomOAuth } from '../../custom-oauth/server/custom_oauth_server'; +import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; import { settings } from '../../settings/server'; -const config = { +const config: Partial = { serverURL: '', authorizePath: '/m/oauth2/auth/', tokenPath: '/m/oauth2/token/', @@ -20,8 +22,6 @@ const config = { accessTokenParam: 'access_token', }; -const Dolphin = new CustomOAuth('dolphin', config); - function DolphinOnCreateUser(options: any, user?: IUser) { // TODO: callbacks Fix this if (user?.services?.dolphin?.NickName) { @@ -30,11 +30,32 @@ function DolphinOnCreateUser(options: any, user?: IUser) { return options; } +const configureDolphinOAuth = () => { + passport.unuse('dolphin'); + + const enabled = settings.get('Accounts_OAuth_Dolphin'); + if (!enabled) { + return; + } + + const serverURL = settings.get('Accounts_OAuth_Dolphin_URL').trim().replace(/\/*$/, ''); + const clientId = settings.get('Accounts_OAuth_Dolphin_id'); + const clientSecret = settings.get('Accounts_OAuth_Dolphin_secret'); + + if (!clientId || !clientSecret || !serverURL) { + return; + } + + addPassportCustomOAuth('dolphin', { ...config, serverURL, clientId, clientSecret }); +}; + Meteor.startup(async () => { - settings.watch('Accounts_OAuth_Dolphin_URL', (value) => { - config.serverURL = value; - return Dolphin.configure(config); - }); + const updateConfig = () => _.debounce(configureDolphinOAuth, 300); + + settings.watchMultiple( + ['Accounts_OAuth_Dolphin', 'Accounts_OAuth_Dolphin_URL', 'Accounts_OAuth_Dolphin_id', 'Accounts_OAuth_Dolphin_secret'], + updateConfig, + ); if (settings.get('Accounts_OAuth_Dolphin_URL')) { const data = { diff --git a/apps/meteor/app/drupal/server/lib.ts b/apps/meteor/app/drupal/server/lib.ts index d137551fb8377..a4f71bd81696f 100644 --- a/apps/meteor/app/drupal/server/lib.ts +++ b/apps/meteor/app/drupal/server/lib.ts @@ -1,14 +1,12 @@ -import type { OauthConfig } from '@rocket.chat/core-typings'; +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; +import passport from 'passport'; +import _ from 'underscore'; -import { CustomOAuth } from '../../custom-oauth/server/custom_oauth_server'; +import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; import { settings } from '../../settings/server'; -// Drupal Server CallBack URL needs to be http(s)://{rocketchat.server}[:port]/_oauth/drupal -// In RocketChat -> Administration the URL needs to be http(s)://{drupal.server}/ - -const config: OauthConfig = { - serverURL: '', +const config: Partial = { identityPath: '/oauth2/UserInfo', authorizePath: '/oauth2/authorize', tokenPath: '/oauth2/token', @@ -23,11 +21,29 @@ const config: OauthConfig = { accessTokenParam: 'access_token', }; -const Drupal = new CustomOAuth('drupal', config); +const configureDrupalOAuth = () => { + passport.unuse('drupal'); + const enabled = settings.get('Accounts_OAuth_Drupal'); + if (!enabled) { + return; + } + + const serverURL = settings.get('API_Drupal_URL').trim().replace(/\/*$/, ''); + const clientId = settings.get('Accounts_OAuth_Drupal_id'); + const clientSecret = settings.get('Accounts_OAuth_Drupal_secret'); + + if (!clientId || !clientSecret || !serverURL) { + return; + } + + addPassportCustomOAuth('drupal', { ...config, serverURL, clientId, clientSecret }); +}; Meteor.startup(() => { - settings.watch('API_Drupal_URL', (value) => { - config.serverURL = value; - Drupal.configure(config); - }); + const updateConfig = _.debounce(configureDrupalOAuth, 300); + + settings.watchMultiple( + ['Accounts_OAuth_Drupal', 'API_Drupal_URL', 'Accounts_OAuth_Drupal_id', 'Accounts_OAuth_Drupal_secret'], + updateConfig, + ); }); diff --git a/apps/meteor/app/gitlab/server/lib.ts b/apps/meteor/app/gitlab/server/lib.ts index 0f4d330cf5970..3df1c1ded5496 100644 --- a/apps/meteor/app/gitlab/server/lib.ts +++ b/apps/meteor/app/gitlab/server/lib.ts @@ -1,11 +1,12 @@ -import type { OauthConfig } from '@rocket.chat/core-typings'; +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; +import passport from 'passport'; import _ from 'underscore'; -import { CustomOAuth } from '../../custom-oauth/server/custom_oauth_server'; +import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; import { settings } from '../../settings/server'; -const config: OauthConfig = { +const config: Partial = { serverURL: 'https://gitlab.com', identityPath: '/api/v4/user', scope: 'read_user', @@ -17,15 +18,39 @@ const config: OauthConfig = { accessTokenParam: 'access_token', }; -const Gitlab = new CustomOAuth('gitlab', config); +const configureGitlabOAuth = () => { + passport.unuse('gitlab'); + + const enabled = settings.get('Accounts_OAuth_Gitlab'); + if (!enabled) { + return; + } + + const clientId = settings.get('Accounts_OAuth_Gitlab_id'); + const clientSecret = settings.get('Accounts_OAuth_Gitlab_secret'); + const serverURL = settings.get('API_Gitlab_URL').trim().replace(/\/*$/, '') || config.serverURL; + const identityPath = settings.get('Accounts_OAuth_Gitlab_identity_path') || config.identityPath; + const mergeUsers = Boolean(settings.get('Accounts_OAuth_Gitlab_merge_users')); + + if (!clientId || !clientSecret) { + return; + } + + addPassportCustomOAuth('gitlab', { ...config, clientId, clientSecret, serverURL, identityPath, mergeUsers }); +}; Meteor.startup(() => { - const updateConfig = _.debounce(() => { - config.serverURL = settings.get('API_Gitlab_URL').trim().replace(/\/*$/, '') || config.serverURL; - config.identityPath = settings.get('Accounts_OAuth_Gitlab_identity_path') || config.identityPath; - config.mergeUsers = Boolean(settings.get('Accounts_OAuth_Gitlab_merge_users')); - Gitlab.configure(config); - }, 300); - - settings.watchMultiple(['API_Gitlab_URL', 'Accounts_OAuth_Gitlab_identity_path', 'Accounts_OAuth_Gitlab_merge_users'], updateConfig); + const updateConfig = _.debounce(configureGitlabOAuth, 300); + + settings.watchMultiple( + [ + 'Accounts_OAuth_Gitlab', + 'API_Gitlab_URL', + 'Accounts_OAuth_Gitlab_id', + 'Accounts_OAuth_Gitlab_secret', + 'Accounts_OAuth_Gitlab_identity_path', + 'Accounts_OAuth_Gitlab_merge_users', + ], + updateConfig, + ); }); diff --git a/apps/meteor/app/lib/server/methods/createToken.ts b/apps/meteor/app/lib/server/methods/createToken.ts index 63de5b98f4210..6e8e2538ab522 100644 --- a/apps/meteor/app/lib/server/methods/createToken.ts +++ b/apps/meteor/app/lib/server/methods/createToken.ts @@ -16,7 +16,7 @@ export async function generateAccessToken(userId: string, secret: string) { } const token = Accounts._generateStampedLoginToken(); - Accounts._insertLoginToken(userId, token); + await Accounts._insertLoginToken(userId, token); await User.ensureLoginTokensLimit(userId); diff --git a/apps/meteor/app/linkedin/server/index.ts b/apps/meteor/app/linkedin/server/index.ts new file mode 100644 index 0000000000000..cf327e4971bb2 --- /dev/null +++ b/apps/meteor/app/linkedin/server/index.ts @@ -0,0 +1 @@ +import './lib'; diff --git a/apps/meteor/app/linkedin/server/lib.ts b/apps/meteor/app/linkedin/server/lib.ts new file mode 100644 index 0000000000000..6fb0d450d2fda --- /dev/null +++ b/apps/meteor/app/linkedin/server/lib.ts @@ -0,0 +1,49 @@ +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; +import passport from 'passport'; + +import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; +import { settings } from '../../settings/server'; + +const config: Partial = { + serverURL: 'https://www.linkedin.com', + authorizePath: '/oauth/v2/authorization', + tokenPath: '/oauth/v2/accessToken', + identityPath: 'https://api.linkedin.com/v2/userinfo', + scope: 'openid email profile', + tokenSentVia: 'header', + addAutopublishFields: { + forLoggedInUser: ['services.linkedin'], + forOtherUsers: ['services.linkedin.name'], + }, + pkce: false, + emailField: 'email', + avatarField: 'picture', +}; + +const serviceKey = 'linkedin'; + +const configureLinkedInOAuth = (): void => { + passport.unuse(serviceKey); + + const enabled = settings.get('Accounts_OAuth_Linkedin'); + if (!enabled) { + return; + } + + const clientId = settings.get('Accounts_OAuth_Linkedin_id'); + const clientSecret = settings.get('Accounts_OAuth_Linkedin_secret'); + + if (!clientId || !clientSecret) { + return; + } + + addPassportCustomOAuth(serviceKey, { ...config, clientId, clientSecret }); +}; + +Meteor.startup(() => { + settings.watchMultiple( + ['Accounts_OAuth_Linkedin', 'Accounts_OAuth_Linkedin_id', 'Accounts_OAuth_Linkedin_secret'], + configureLinkedInOAuth, + ); +}); diff --git a/apps/meteor/app/meteor-developer/server/index.ts b/apps/meteor/app/meteor-developer/server/index.ts new file mode 100644 index 0000000000000..cf327e4971bb2 --- /dev/null +++ b/apps/meteor/app/meteor-developer/server/index.ts @@ -0,0 +1 @@ +import './lib'; diff --git a/apps/meteor/app/meteor-developer/server/lib.ts b/apps/meteor/app/meteor-developer/server/lib.ts new file mode 100644 index 0000000000000..cb532732ead92 --- /dev/null +++ b/apps/meteor/app/meteor-developer/server/lib.ts @@ -0,0 +1,46 @@ +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; +import passport from 'passport'; + +import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; +import { settings } from '../../settings/server'; + +const config: Partial = { + serverURL: 'https://www.meteor.com', + authorizePath: '/oauth2/authorize', + tokenPath: '/oauth2/token', + identityPath: '/api/v1/identity', + scope: 'email', + tokenSentVia: 'header', + addAutopublishFields: { + forLoggedInUser: ['services.meteor-developer'], + forOtherUsers: ['services.meteor-developer.username'], + }, +}; + +const serviceKey = 'meteor-developer'; + +const configureMeteorDeveloperOAuth = (): void => { + passport.unuse(serviceKey); + + const enabled = settings.get('Accounts_OAuth_Meteor'); + if (!enabled) { + return; + } + + const clientId = settings.get('Accounts_OAuth_Meteor_id'); + const clientSecret = settings.get('Accounts_OAuth_Meteor_secret'); + + if (!clientId || !clientSecret) { + return; + } + + addPassportCustomOAuth(serviceKey, { ...config, clientId, clientSecret }); +}; + +Meteor.startup(() => { + settings.watchMultiple( + ['Accounts_OAuth_Meteor', 'Accounts_OAuth_Meteor_id', 'Accounts_OAuth_Meteor_secret'], + configureMeteorDeveloperOAuth, + ); +}); diff --git a/apps/meteor/app/nextcloud/server/lib.ts b/apps/meteor/app/nextcloud/server/lib.ts index 28cf52da57a17..fc2b592b7c1ad 100644 --- a/apps/meteor/app/nextcloud/server/lib.ts +++ b/apps/meteor/app/nextcloud/server/lib.ts @@ -1,14 +1,12 @@ -import type { OauthConfig } from '@rocket.chat/core-typings'; +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; -import _ from 'underscore'; -import { CustomOAuth } from '../../custom-oauth/server/custom_oauth_server'; -import { settings } from '../../settings/server'; +import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; +import { settings } from '../../settings/server/cached'; -const config: OauthConfig = { - serverURL: '', +const NEXTCLOUD_PATHS = { tokenPath: '/index.php/apps/oauth2/api/v1/token', - tokenSentVia: 'header', + tokenSentVia: 'header' as OAuthConfiguration['tokenSentVia'], authorizePath: '/index.php/apps/oauth2/authorize', identityPath: '/ocs/v2.php/cloud/user?format=json', scope: 'openid', @@ -18,20 +16,31 @@ const config: OauthConfig = { }, }; -const Nextcloud = new CustomOAuth('nextcloud', config); +function configureNextcloudOAuth(): void { + const enabled = settings.get('Accounts_OAuth_Nextcloud'); + if (!enabled) { + return; + } -const fillServerURL = _.debounce((): void => { - const nextcloudURL = settings.get('Accounts_OAuth_Nextcloud_URL'); - if (!nextcloudURL) { - if (nextcloudURL === undefined) { - return fillServerURL(); - } + const serverURL = settings.get('Accounts_OAuth_Nextcloud_URL')?.trim().replace(/\/*$/, ''); + const clientId = settings.get('Accounts_OAuth_Nextcloud_id'); + const clientSecret = settings.get('Accounts_OAuth_Nextcloud_secret'); + + if (!serverURL || !clientId || !clientSecret) { return; } - config.serverURL = nextcloudURL.trim().replace(/\/*$/, ''); - return Nextcloud.configure(config); -}, 1000); + + addPassportCustomOAuth('nextcloud', { + ...NEXTCLOUD_PATHS, + serverURL, + clientId, + clientSecret, + }); +} Meteor.startup(() => { - settings.watch('Accounts_OAuth_Nextcloud_URL', () => fillServerURL()); + settings.watchMultiple( + ['Accounts_OAuth_Nextcloud', 'Accounts_OAuth_Nextcloud_URL', 'Accounts_OAuth_Nextcloud_id', 'Accounts_OAuth_Nextcloud_secret'], + configureNextcloudOAuth, + ); }); diff --git a/apps/meteor/app/wordpress/server/lib.ts b/apps/meteor/app/wordpress/server/lib.ts index 7777ea1382215..eb1ce2c01ceb2 100644 --- a/apps/meteor/app/wordpress/server/lib.ts +++ b/apps/meteor/app/wordpress/server/lib.ts @@ -1,12 +1,13 @@ -import type { OauthConfig } from '@rocket.chat/core-typings'; +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { ServiceConfiguration } from 'meteor/service-configuration'; +import passport from 'passport'; import _ from 'underscore'; -import { CustomOAuth } from '../../custom-oauth/server/custom_oauth_server'; +import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; import { settings } from '../../settings/server'; -const config: OauthConfig = { +const config: Partial = { serverURL: '', identityPath: '/oauth/me', @@ -17,7 +18,7 @@ const config: OauthConfig = { accessTokenParam: 'access_token', }; -const WordPress = new CustomOAuth('wordpress', config); +const serviceKey = 'wordpress'; const fillSettings = _.debounce(async (): Promise => { config.serverURL = settings.get('API_Wordpress_URL'); @@ -28,11 +29,7 @@ const fillSettings = _.debounce(async (): Promise => { return; } - delete config.identityPath; - delete config.identityTokenSentVia; - delete config.authorizePath; - delete config.tokenPath; - delete config.scope; + passport.unuse(serviceKey); const serverType = settings.get('Accounts_OAuth_Wordpress_server_type'); switch (serverType) { @@ -59,7 +56,7 @@ const fillSettings = _.debounce(async (): Promise => { break; case 'wordpress-com': config.identityPath = 'https://public-api.wordpress.com/rest/v1/me'; - config.identityTokenSentVia = 'header'; + config.identityTokenSentVia = 'header' as OAuthConfiguration['identityTokenSentVia']; config.authorizePath = 'https://public-api.wordpress.com/oauth2/authorize'; config.tokenPath = 'https://public-api.wordpress.com/oauth2/token'; config.scope = 'auth'; @@ -69,12 +66,13 @@ const fillSettings = _.debounce(async (): Promise => { break; } - const result = WordPress.configure(config); + addPassportCustomOAuth(serviceKey, config); + const enabled = settings.get('Accounts_OAuth_Wordpress'); if (enabled) { await ServiceConfiguration.configurations.upsertAsync( { - service: 'wordpress', + service: serviceKey, }, { $set: config, @@ -82,11 +80,9 @@ const fillSettings = _.debounce(async (): Promise => { ); } else { await ServiceConfiguration.configurations.removeAsync({ - service: 'wordpress', + service: serviceKey, }); } - - return result; }, 1000); Meteor.startup(() => { diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx index c80fb09e78ae1..14f86a832e075 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx @@ -1,7 +1,7 @@ import { Box, Button } from '@rocket.chat/fuselage'; import { FieldGroup, TextInput, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage-forms'; import { GenericModal } from '@rocket.chat/ui-client'; -import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -12,14 +12,14 @@ import { Method } from './TwoFactorModal'; type TwoFactorEmailModalProps = { onConfirm: OnConfirm; onClose: () => void; - emailOrUsername: string; + resendEmail?: () => Promise; }; type TwoFactorEmailFormData = { code: string; }; -const TwoFactorEmailModal = ({ onConfirm, onClose, emailOrUsername }: TwoFactorEmailModalProps): ReactElement => { +const TwoFactorEmailModal = ({ onConfirm, onClose, resendEmail }: TwoFactorEmailModalProps): ReactElement => { const dispatchToastMessage = useToastMessageDispatch(); const { t } = useTranslation(); @@ -33,11 +33,12 @@ const TwoFactorEmailModal = ({ onConfirm, onClose, emailOrUsername }: TwoFactorE defaultValues: { code: '' }, }); - const sendEmailCode = useEndpoint('POST', '/v1/users.2fa.sendEmailCode'); - const onClickResendCode = async (): Promise => { try { - await sendEmailCode({ emailOrUsername }); + if (!resendEmail) { + throw new Error('resendEmail is not defined'); + } + await resendEmail(); dispatchToastMessage({ type: 'success', message: t('Email_sent') }); } catch (error) { dispatchToastMessage({ diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx index ef056ee717ccb..04f603882c531 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx @@ -21,7 +21,7 @@ type TwoFactorModalProps = { } | { method: 'email'; - emailOrUsername: string; + resendEmail?: () => Promise; } ); @@ -31,9 +31,9 @@ const TwoFactorModal = ({ onConfirm, onClose, ...props }: TwoFactorModalProps): } if (props.method === Method.EMAIL) { - const { emailOrUsername } = props; + const { resendEmail } = props; - return ; + return ; } if (props.method === Method.PASSWORD) { diff --git a/apps/meteor/client/lib/2fa/process2faReturn.ts b/apps/meteor/client/lib/2fa/process2faReturn.ts index 50a0d729d770b..7bdac41bc1f50 100644 --- a/apps/meteor/client/lib/2fa/process2faReturn.ts +++ b/apps/meteor/client/lib/2fa/process2faReturn.ts @@ -5,6 +5,7 @@ import { lazy } from 'react'; import type { LoginCallback } from './overrideLoginMethod'; import type { MeteorErrorLike } from './types'; import { isTotpInvalidError, isTotpRequiredError } from './utils'; +import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { getUser } from '../user'; const TwoFactorModal = lazy(() => import('../../components/TwoFactorModal')); @@ -31,7 +32,9 @@ const hasRequiredTwoFactorMethod = ( function assertModalProps(props: { method: TwoFactorMethod; emailOrUsername?: string; -}): asserts props is { method: 'totp' } | { method: 'password' } | { method: 'email'; emailOrUsername: string } { +}): asserts props is + | { method: 'totp' | 'password'; invalidAttempt?: boolean } + | { method: 'email'; emailOrUsername: string; invalidAttempt?: boolean } { if (props.method === 'email' && typeof props.emailOrUsername !== 'string') { throw new Error('Invalid Two Factor method'); } @@ -161,6 +164,10 @@ export const invokeTwoFactorModal = async ( reject(new Error('totp-canceled')); } }, + ...(props.method === 'email' && + props.emailOrUsername && { + resendEmail: (): Promise => sdk.rest.post('/v1/users.2fa.sendEmailCode', { emailOrUsername: props.emailOrUsername }), + }), }, }); }); diff --git a/apps/meteor/client/lib/buildAuthDeeplinkURL.ts b/apps/meteor/client/lib/buildAuthDeeplinkURL.ts new file mode 100644 index 0000000000000..e4481fed271d3 --- /dev/null +++ b/apps/meteor/client/lib/buildAuthDeeplinkURL.ts @@ -0,0 +1,5 @@ +export const buildDeepLinkURL = (resumeToken: string, userId: string) => { + const url = new URL(window.location.href); + const { origin } = url; + return `rocketchat://auth?host=${origin}&token=${resumeToken}&userId=${userId}`; +}; diff --git a/apps/meteor/client/lib/sdk/ddpSdk.ts b/apps/meteor/client/lib/sdk/ddpSdk.ts index 35ed4169e29e3..09c2b43ac10a7 100644 --- a/apps/meteor/client/lib/sdk/ddpSdk.ts +++ b/apps/meteor/client/lib/sdk/ddpSdk.ts @@ -68,7 +68,7 @@ export const getDdpSdk = (): DDPSDK => { return instance; }; -const readStoredLoginToken = (): string | null => getStoredItem(STORAGE_KEYS.LOGIN_TOKEN); +export const readStoredLoginToken = (): string | null => getStoredItem(STORAGE_KEYS.LOGIN_TOKEN); let inflightLogin: Promise | undefined; diff --git a/apps/meteor/client/startup/routes.tsx b/apps/meteor/client/startup/routes.tsx index 77f76158cb65d..2b8e1c43e35e2 100644 --- a/apps/meteor/client/startup/routes.tsx +++ b/apps/meteor/client/startup/routes.tsx @@ -3,6 +3,7 @@ import { createElement, lazy, useEffect } from 'react'; import { appLayout } from '../lib/appLayout'; import { router } from '../providers/RouterProvider'; +import OAuthTwoFactorAuthenticationRouter from '../views/OAuthTwoFactorAuthentication/OAuthTwoFactorAuthenticationRouter'; import MainLayout from '../views/root/MainLayout'; const IndexRoute = lazy(() => import('../views/root/IndexRoute')); @@ -39,6 +40,10 @@ declare module '@rocket.chat/ui-contexts' { pathname: `/meet/${string}`; pattern: '/meet/:rid'; }; + '2fa': { + pathname: `/2fa/${string}/${string}`; + pattern: '/2fa/:method/:challengeId'; + }; 'home': { pathname: '/home'; pattern: '/home'; @@ -131,6 +136,11 @@ router.defineRoutes([ return null; }), }, + { + path: '/2fa/:method/:challengeId', + id: '2fa', + element: appLayout.wrap(), + }, { path: '/home', id: 'home', diff --git a/apps/meteor/client/views/OAuthTwoFactorAuthentication/OAuthTwoFactorAuthenticationRouter.tsx b/apps/meteor/client/views/OAuthTwoFactorAuthentication/OAuthTwoFactorAuthenticationRouter.tsx new file mode 100644 index 0000000000000..2394d0a8cfdaf --- /dev/null +++ b/apps/meteor/client/views/OAuthTwoFactorAuthentication/OAuthTwoFactorAuthenticationRouter.tsx @@ -0,0 +1,101 @@ +import { useStableCallback } from '@rocket.chat/fuselage-hooks'; +import { Page } from '@rocket.chat/ui-client'; +import { + useEndpoint, + useLoginWithToken, + useRouteParameter, + useRouter, + useSetModal, + useToastMessageDispatch, +} from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import TwoFactorModal from '../../components/TwoFactorModal/TwoFactorModal'; + +const throwErrorOnInvalidMethod = (method: never): never => { + throw new Error(`Invalid Two Factor method: ${method}`); +}; + +const OAuthTwoFactorAuthenticationRouter = () => { + const method = useRouteParameter('method') as 'totp' | 'email' | undefined; + const challengeId = useRouteParameter('challengeId'); + const router = useRouter(); + const dispatchToastMessage = useToastMessageDispatch(); + const setModal = useSetModal(); + const loginWithToken = useLoginWithToken(); + const { t } = useTranslation(); + const verifyChallenge = useEndpoint('POST', '/v1/twoFactorChallenges.verifyChallenge'); + const sendEmailCode = useEndpoint('POST', '/v1/twoFactorChallenges.sendEmailCode'); + + const navigateToHome = useStableCallback(() => { + setModal(null); + router.navigate('/home', { replace: true }); + }); + + const resendEmail = useStableCallback(async () => { + if (!challengeId) { + return null; + } + await sendEmailCode({ challengeId }); + return null; + }); + + const onConfirm = useStableCallback(async (code: string) => { + if (!challengeId || !code) { + return; + } + try { + const { loginToken, userId } = await verifyChallenge({ challengeId, code }); + + const { loginClient } = router.getSearchParameters(); + + if (loginClient === 'mobile' || loginClient === 'desktop') { + setModal(null); + router.navigate({ name: 'home', search: { resumeToken: loginToken, userId, loginClient } }, { replace: true }); + return; + } + + await loginWithToken(loginToken); + navigateToHome(); + } catch (error: any) { + console.error('Failed to verify challenge', error); + if (error.errorType === 'totp-max-attempts') { + setModal(null); + dispatchToastMessage({ type: 'error', message: t('Maximum_number_of_attempts_reached_please_try_again_later') }); + router.navigate('/login', { replace: true }); + return; + } + if (error.errorType === 'error-challenge-expired' || error.errorType === 'error-challenge-not-found') { + setModal(null); + dispatchToastMessage({ type: 'error', message: t('Challenge_expired_please_try_again_later') }); + router.navigate('/login', { replace: true }); + return; + } + throw error; + } + }); + + useEffect(() => { + if (!method || !challengeId) { + router.navigate('/home'); + return; + } + + if (method === 'email') { + setModal(); + return; + } + + if (method === 'totp') { + setModal(); + return; + } + + throwErrorOnInvalidMethod(method); + }, [method, challengeId, router, setModal, onConfirm, resendEmail, navigateToHome]); + + return ; +}; + +export default OAuthTwoFactorAuthenticationRouter; diff --git a/apps/meteor/client/views/root/AppLayout.tsx b/apps/meteor/client/views/root/AppLayout.tsx index b355d9c7b44f2..250690e98d78f 100644 --- a/apps/meteor/client/views/root/AppLayout.tsx +++ b/apps/meteor/client/views/root/AppLayout.tsx @@ -27,11 +27,13 @@ import { useKeyboardShortcutsHotkey } from './hooks/useKeyboardShortcutsHotkey'; import { useLivechatEnterprise } from './hooks/useLivechatEnterprise'; import { useLoadMissedMessages } from './hooks/useLoadMissedMessages'; import { useLoadRoomForAllowedAnonymousRead } from './hooks/useLoadRoomForAllowedAnonymousRead'; +import { useLoginOtherClients } from './hooks/useLoginOtherClients'; import { useLoginViaQuery } from './hooks/useLoginViaQuery'; import { useMessageLinkClicks } from './hooks/useMessageLinkClicks'; import { useNotificationPermission } from './hooks/useNotificationPermission'; import { useRedirectToSetupWizard } from './hooks/useRedirectToSetupWizard'; import { useSettingsOnLoadSiteUrl } from './hooks/useSettingsOnLoadSiteUrl'; +import { useShareSessionWithOtherClients } from './hooks/useShareSessionWithOtherClients'; import { useStartupEvent } from './hooks/useStartupEvent'; import { appLayout } from '../../lib/appLayout'; @@ -70,6 +72,8 @@ const AppLayout = () => { useAutoupdate(); useCodeHighlight(); useLoginViaQuery(); + useLoginOtherClients(); + useShareSessionWithOtherClients(); useLoadMissedMessages(); useDesktopFavicon(); useDesktopTitle(); diff --git a/apps/meteor/client/views/root/hooks/useLoginOtherClients.ts b/apps/meteor/client/views/root/hooks/useLoginOtherClients.ts new file mode 100644 index 0000000000000..a826091886772 --- /dev/null +++ b/apps/meteor/client/views/root/hooks/useLoginOtherClients.ts @@ -0,0 +1,30 @@ +import { useRouter, useSearchParameter } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { buildDeepLinkURL } from '../../../lib/buildAuthDeeplinkURL'; + +export const useLoginOtherClients = () => { + const router = useRouter(); + const resumeToken = useSearchParameter('resumeToken'); + const loginClient = useSearchParameter('loginClient'); + const userId = useSearchParameter('userId'); + + useEffect(() => { + if (!resumeToken || !userId) { + return; + } + + if (loginClient !== 'desktop' && loginClient !== 'mobile') { + return; + } + + const loginURL = buildDeepLinkURL(resumeToken, userId); + window.location.href = loginURL; + + const timeout = setTimeout(() => { + router.navigate('/home', { replace: true }); + }, 0); + + return () => clearTimeout(timeout); + }, [resumeToken, userId, loginClient, router]); +}; diff --git a/apps/meteor/client/views/root/hooks/useLoginViaQuery.ts b/apps/meteor/client/views/root/hooks/useLoginViaQuery.ts index a67739eb7b025..7bc3b2b93ecb0 100644 --- a/apps/meteor/client/views/root/hooks/useLoginViaQuery.ts +++ b/apps/meteor/client/views/root/hooks/useLoginViaQuery.ts @@ -7,12 +7,17 @@ export const useLoginViaQuery = () => { useEffect(() => { const handleLogin = async () => { - const { resumeToken } = router.getSearchParameters(); + const { resumeToken, loginClient } = router.getSearchParameters(); if (!resumeToken) { return; } + //Case handled by useLoginOtherClients, we don't want to login here. + if (loginClient) { + return; + } + try { await loginWithToken(resumeToken); diff --git a/apps/meteor/client/views/root/hooks/useShareSessionWithOtherClients.ts b/apps/meteor/client/views/root/hooks/useShareSessionWithOtherClients.ts new file mode 100644 index 0000000000000..62fd2fee04818 --- /dev/null +++ b/apps/meteor/client/views/root/hooks/useShareSessionWithOtherClients.ts @@ -0,0 +1,41 @@ +import { useRouter, useSearchParameter, useUserId } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { buildDeepLinkURL } from '../../../lib/buildAuthDeeplinkURL'; +import { readStoredLoginToken } from '../../../lib/sdk/ddpSdk'; + +export const useShareSessionWithOtherClients = () => { + const router = useRouter(); + const userId = useUserId(); + const resumeToken = useSearchParameter('resumeToken'); + const loginClient = useSearchParameter('loginClient'); + + useEffect(() => { + if (!userId) { + return; + } + + const loginToken = readStoredLoginToken(); + + if (!loginToken) { + return; + } + + if (resumeToken) { + return; + } + + if (loginClient !== 'desktop' && loginClient !== 'mobile') { + return; + } + + const loginURL = buildDeepLinkURL(loginToken, userId); + window.location.href = loginURL; + + const timeout = setTimeout(() => { + router.navigate('/home', { replace: true }); + }, 100); + + return () => clearTimeout(timeout); + }, [resumeToken, loginClient, router, userId]); +}; diff --git a/apps/meteor/definition/externals/express-session.d.ts b/apps/meteor/definition/externals/express-session.d.ts new file mode 100644 index 0000000000000..6a59655d241a7 --- /dev/null +++ b/apps/meteor/definition/externals/express-session.d.ts @@ -0,0 +1,7 @@ +import 'express-session'; + +declare module 'express-session' { + interface SessionData { + loginClient?: string; + } +} diff --git a/apps/meteor/definition/externals/express.d.ts b/apps/meteor/definition/externals/express.d.ts index 1661385d94f77..251d8310daf9f 100644 --- a/apps/meteor/definition/externals/express.d.ts +++ b/apps/meteor/definition/externals/express.d.ts @@ -9,3 +9,9 @@ declare module 'express' { unauthorized?: boolean; } } +declare global { + namespace Express { + // eslint-disable-next-line @typescript-eslint/no-empty-interface -- merges with Passport's Express.User + interface User extends IUser {} + } +} diff --git a/apps/meteor/definition/externals/meteor/accounts-base.d.ts b/apps/meteor/definition/externals/meteor/accounts-base.d.ts index 39ea55788e9a7..9676c1c6d8678 100644 --- a/apps/meteor/definition/externals/meteor/accounts-base.d.ts +++ b/apps/meteor/definition/externals/meteor/accounts-base.d.ts @@ -23,7 +23,7 @@ declare module 'meteor/accounts-base' { function _generateStampedLoginToken(): { token: string; when: Date }; - function _insertLoginToken(userId: string, token: { token: string; when: Date }): void; + function _insertLoginToken(userId: string, token: { token: string; when: Date }): Promise; function _runLoginHandlers(methodInvocation: T, loginRequest: Record): Promise; @@ -41,7 +41,9 @@ declare module 'meteor/accounts-base' { serviceName: string, serviceData: Record, options: Record, - ): Record; + ): Promise | undefined>; + + function addAutopublishFields(options: Record): void; function _clearAllLoginTokens(userId: string | null): void; diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 3efecb87c4ac8..8b8bb2d4ce7f1 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -183,6 +183,7 @@ "colorette": "^2.0.20", "colors": "^1.4.0", "connect": "^3.7.0", + "connect-mongo": "5.1.0", "cookie": "^0.7.2", "cookie-parser": "^1.4.7", "cors": "^2.8.6", @@ -205,6 +206,7 @@ "expiry-map": "^2.0.0", "express": "^4.21.2", "express-rate-limit": "^5.5.1", + "express-session": "^1.19.0", "fast-redact": "^3.5.0", "fastq": "^1.17.1", "fflate": "^0.8.2", @@ -261,6 +263,13 @@ "object-path": "^0.11.8", "overlayscrollbars": "~2.11.5", "overlayscrollbars-react": "^0.5.6", + "passport": "^0.7.0", + "passport-apple": "^2.0.2", + "passport-facebook": "^3.0.0", + "passport-github2": "^0.1.12", + "passport-google-oauth20": "^2.0.0", + "passport-oauth2": "^1.8.0", + "passport-twitter": "^1.0.4", "path": "^0.12.7", "path-to-regexp": "^6.3.0", "pino": "10.3.1", @@ -356,6 +365,7 @@ "@types/ejson": "^2.2.2", "@types/express": "^4.17.25", "@types/express-rate-limit": "^5.1.3", + "@types/express-session": "^1", "@types/fast-redact": "^3", "@types/google-libphonenumber": "^7.4.30", "@types/gravatar": "^1.8.6", @@ -386,6 +396,13 @@ "@types/oauth2-server": "^3.0.18", "@types/object-path": "^0.11.4", "@types/parseurl": "^1.3.3", + "@types/passport": "^1.0.17", + "@types/passport-apple": "^2", + "@types/passport-facebook": "^3.0.4", + "@types/passport-github2": "^1.2.9", + "@types/passport-google-oauth20": "^2", + "@types/passport-oauth2": "^1", + "@types/passport-twitter": "^1", "@types/prometheus-gc-stats": "^0.6.4", "@types/proxy-from-env": "^1.0.4", "@types/proxyquire": "^1.3.31", diff --git a/apps/meteor/server/configuration/accounts_meld.js b/apps/meteor/server/configuration/accounts_meld.js index 037150def37ee..2c8fff11a820b 100644 --- a/apps/meteor/server/configuration/accounts_meld.js +++ b/apps/meteor/server/configuration/accounts_meld.js @@ -18,10 +18,6 @@ export async function configureAccounts() { } } - if (serviceName === 'linkedin') { - serviceData.email = serviceData.emailAddress; - } - if (serviceData.email) { const user = await Users.findOneByEmailAddress(serviceData.email); if (user != null && user.services?.[serviceName]?.id !== serviceData.id) { diff --git a/apps/meteor/server/configuration/configurePassport.ts b/apps/meteor/server/configuration/configurePassport.ts new file mode 100644 index 0000000000000..48c9ccd9d85cc --- /dev/null +++ b/apps/meteor/server/configuration/configurePassport.ts @@ -0,0 +1,91 @@ +import { Users } from '@rocket.chat/models'; +import bodyParser from 'body-parser'; +import MongoStore from 'connect-mongo'; +import express from 'express'; +import rateLimit from 'express-rate-limit'; +import session from 'express-session'; +import { MongoInternals } from 'meteor/mongo'; +import { WebApp } from 'meteor/webapp'; +import passport from 'passport'; + +import type { ICachedSettings } from '../../app/settings/server/CachedSettings'; +import { configureOAuthServices } from '../lib/oauth/configureOAuthServices'; +import { createOAuthServiceConfig } from '../lib/oauth/createOAuthServiceConfig'; +import { getOAuthServices } from '../lib/oauth/getOAuthServices'; + +const oAuthPaths = ['/oauth', '/_oauth']; + +const { Router: router } = express; + +export const oAuthRouter = router(); + +const oAuthApp = express(); +oAuthApp.set('trust proxy', true); + +export const configurePassport = (settings: ICachedSettings) => { + const { client } = MongoInternals.defaultRemoteCollectionDriver().mongo; + + oAuthApp.use( + oAuthPaths, + session({ + name: 'oauth', + secret: settings.get('Accounts_OAuth_Session_Secret'), + resave: false, + saveUninitialized: false, + proxy: true, + store: MongoStore.create({ + client, + collectionName: 'rocketchat_oauth_sessions', + ttl: 5 * 60, + autoRemove: 'native', + }), + cookie: { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 5 * 60 * 1000, // 5 minutes + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', + }, + }), + ); + + oAuthApp.use(oAuthPaths, passport.initialize()); + oAuthApp.use(oAuthPaths, passport.session()); + oAuthApp.use(oAuthPaths, bodyParser.urlencoded({ extended: true })); + + const oauthRateLimiter = rateLimit({ + windowMs: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), + max: settings.get('API_Enable_Rate_Limiter_Limit_Calls_Default'), + skip: () => + settings.get('API_Enable_Rate_Limiter') !== true || + (process.env.NODE_ENV === 'development' && settings.get('API_Enable_Rate_Limiter_Dev') !== true), + handler: (_req, res) => { + res.status(429).json({ + success: false, + error: 'Too many requests. Please try again later.', + }); + }, + }); + + oAuthRouter.use(oAuthPaths, oauthRateLimiter); + + // Register OAuth Routes + oAuthApp.use(oAuthRouter); + + passport.serializeUser((user: any, done) => { + done(null, user._id); + }); + + passport.deserializeUser(async (id, done) => { + const user = await Users.findOneById(id as string); + // we don’t actually use this user later + done(null, user); + }); + + settings.watchByRegex(/^(Accounts_OAuth_)[a-z0-9_]+$/i, () => { + const services = getOAuthServices(settings); + const oauthServiceConfigs = createOAuthServiceConfig(settings, services); + configureOAuthServices(oauthServiceConfigs, settings); + }); + + WebApp.rawConnectHandlers.use(oAuthApp); +}; diff --git a/apps/meteor/server/configuration/index.ts b/apps/meteor/server/configuration/index.ts index 491c410e26f4a..79f3160120ba1 100644 --- a/apps/meteor/server/configuration/index.ts +++ b/apps/meteor/server/configuration/index.ts @@ -7,6 +7,7 @@ import { configureCORS } from './configureCORS'; import { configureDirectReply } from './configureDirectReply'; import { configureIRC } from './configureIRC'; import { configureLogLevel } from './configureLogLevel'; +import { configurePassport } from './configurePassport'; import { configureSMTP } from './configureSMTP'; import { configureLDAP } from './ldap'; import { configureOAuth } from './oauth'; @@ -28,5 +29,6 @@ export async function configureServer(settings: ICachedSettings) { configureDirectReply(settings), configureSMTP(settings), configureIRC(settings), + configurePassport(settings), ]); } diff --git a/apps/meteor/server/importPackages.ts b/apps/meteor/server/importPackages.ts index 645ff19932e0a..228d809fe6dc2 100644 --- a/apps/meteor/server/importPackages.ts +++ b/apps/meteor/server/importPackages.ts @@ -33,6 +33,8 @@ import '../app/importer-slack-users/server'; import '../app/integrations/server'; import '../app/irc/server'; import '../app/lib/server'; +import '../app/meteor-developer/server'; +import '../app/linkedin/server'; import '../app/token-login/server'; import '../app/mailer/server/api'; import '../app/markdown/server'; diff --git a/apps/meteor/server/lib/oauth/addPassportCustomOAuth.ts b/apps/meteor/server/lib/oauth/addPassportCustomOAuth.ts new file mode 100644 index 0000000000000..e26f51bdf9c72 --- /dev/null +++ b/apps/meteor/server/lib/oauth/addPassportCustomOAuth.ts @@ -0,0 +1,53 @@ +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import passport from 'passport'; +import type { DoneCallback, Profile } from 'passport'; + +import { passportOAuthCallback } from './passportOAuthCallback'; +import { verifyFunction } from './verifyFunction'; +import { CustomOAuthStrategy } from '../../../app/custom-oauth/server/customOAuth'; +import { settings } from '../../../app/settings/server'; +import { oAuthRouter } from '../../configuration/configurePassport'; + +export const addPassportCustomOAuth = (serviceName: string, config: Partial) => { + passport.unuse(serviceName); + + if (!config.clientId || !config.clientSecret || !config.serverURL) { + return; + } + + passport.use( + serviceName, + new CustomOAuthStrategy( + serviceName, + config as OAuthConfiguration & { clientSecret: string }, + (accessToken: string, refreshToken: string, profile: Profile, done: DoneCallback) => + verifyFunction(accessToken, refreshToken, profile, done, serviceName), + ), + ); + + const siteUrl = settings.get('Site_Url'); + + oAuthRouter.get( + `/oauth/${serviceName}`, + (req, _res, next) => { + const { loginClient } = req.query; + if (loginClient === 'mobile' || loginClient === 'desktop') { + req.session.loginClient = loginClient; + req.session.save(() => { + next(); + }); + } else { + //delete stale value from previous sessions if any + delete req.session.loginClient; + next(); + } + }, + passport.authenticate(serviceName, { scope: config.scope, prompt: 'consent', failureRedirect: '/login', keepSessionInfo: true }), + ); + + oAuthRouter.get( + `/_oauth/${serviceName}`, + passport.authenticate(serviceName, { failureRedirect: '/login', failureFlash: true, failWithError: true, keepSessionInfo: true }), + passportOAuthCallback(siteUrl), + ); +}; diff --git a/apps/meteor/server/lib/oauth/configureOAuthServices.ts b/apps/meteor/server/lib/oauth/configureOAuthServices.ts new file mode 100644 index 0000000000000..5ff0b202a2e89 --- /dev/null +++ b/apps/meteor/server/lib/oauth/configureOAuthServices.ts @@ -0,0 +1,87 @@ +import { Users } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import passport from 'passport'; +import type { Profile, DoneCallback } from 'passport'; + +import type { OAuthServiceConfig } from './createOAuthServiceConfig'; +import { passportOAuthCallback } from './passportOAuthCallback'; +import type { ICachedSettings } from '../../../app/settings/server/CachedSettings'; +import { oAuthRouter } from '../../configuration/configurePassport'; + +export const configureOAuthServices = (oauthServiceConfig: OAuthServiceConfig[], settings: ICachedSettings) => { + oauthServiceConfig.forEach((config) => { + const Strategy = config.strategy; + const siteUrl = settings.get('Site_Url'); + + passport.unuse(config.provider); + + passport.use( + config.provider, + new Strategy( + { + ...config, + clientID: config.clientId, + clientSecret: config.clientSecret, + consumerKey: config.clientId, + consumerSecret: config.clientSecret, + callbackURL: `${siteUrl}/_oauth/${config.provider}`, + state: true, + pkce: true, + profileFields: ['id', 'displayName', 'emails'], + }, + async (accessToken: string, refreshToken: string, profile: Profile, done: DoneCallback) => { + const profileWithRaw = profile as Profile & { _json?: Record; _raw?: string }; + const { _json, _raw, ...restProfile } = profileWithRaw; + + const user = await Accounts.updateOrCreateUserFromExternalService( + config.provider, + { + accessToken, + refreshToken, + name: profile.displayName, + ...restProfile, + ..._json, + email: profile?.emails?.[0]?.value, + }, + {}, + ); + + if (!user?.userId || typeof user?.userId !== 'string') { + return done(new Error('User not found')); + } + + const userFromDB = await Users.findOneById(user.userId); + + if (!userFromDB) { + return done(new Error('User not found')); + } + + return done(null, userFromDB); + }, + ), + ); + + oAuthRouter.get( + `/oauth/${config.provider}`, + (req, _res, next) => { + const { loginClient } = req.query; + if (loginClient === 'mobile' || loginClient === 'desktop') { + req.session.loginClient = loginClient; + req.session.save(() => { + next(); + }); + } else { + //delete stale value from previous sessions if any + delete req.session.loginClient; + next(); + } + }, + passport.authenticate(config.provider, { scope: config.scope, prompt: 'consent', failureRedirect: '/login', keepSessionInfo: true }), + ); + oAuthRouter.get( + `/_oauth/${config.provider}`, + passport.authenticate(config.provider, { failureRedirect: '/login', failureFlash: true, failWithError: true, keepSessionInfo: true }), + passportOAuthCallback(siteUrl), + ); + }); +}; diff --git a/apps/meteor/server/lib/oauth/createOAuthServiceConfig.ts b/apps/meteor/server/lib/oauth/createOAuthServiceConfig.ts new file mode 100644 index 0000000000000..3ea8213140b95 --- /dev/null +++ b/apps/meteor/server/lib/oauth/createOAuthServiceConfig.ts @@ -0,0 +1,59 @@ +import { capitalize } from '@rocket.chat/string-helpers'; +import { isTruthy } from '@rocket.chat/tools'; +import type { Strategy } from 'passport'; + +import { OAuthConfigs } from './oauthConfigs'; +import { type ICachedSettings } from '../../../app/settings/server/CachedSettings'; + +export type OAuthServiceConfig = { + provider: string; + strategy: new (...args: any[]) => Strategy; + clientId: string; + clientSecret: string; + scope?: string[]; +}; + +export const createOAuthServiceConfig = (settings: ICachedSettings, services: string[]): OAuthServiceConfig[] => { + return services + .map((service) => { + if (!OAuthConfigs[service]) { + return; + } + + if (service === 'github_enterprise') { + const clientId = settings.get('Accounts_OAuth_GitHub_Enterprise_id'); + const clientSecret = settings.get('Accounts_OAuth_GitHub_Enterprise_secret'); + const serverUrl = settings.get('API_GitHub_Enterprise_URL'); + + if (!clientId || !clientSecret || !serverUrl) { + return; + } + + return { + provider: service, + clientId, + clientSecret, + authorizationURL: `${serverUrl}/login/oauth/authorize`, + tokenURL: `${serverUrl}/login/oauth/access_token`, + userProfileURL: `${serverUrl}/api/v3/user`, + strategy: OAuthConfigs.github_enterprise.strategy, + scope: OAuthConfigs.github_enterprise.scope, + }; + } + + const clientId = settings.get(`Accounts_OAuth_${capitalize(service)}_id`); + const clientSecret = settings.get(`Accounts_OAuth_${capitalize(service)}_secret`); + + if (!clientId || !clientSecret) { + return; + } + + return { + provider: service, + clientId, + clientSecret, + ...OAuthConfigs[service], + }; + }) + .filter(isTruthy); +}; diff --git a/apps/meteor/server/lib/oauth/getOAuthServices.ts b/apps/meteor/server/lib/oauth/getOAuthServices.ts new file mode 100644 index 0000000000000..7a431982d9f71 --- /dev/null +++ b/apps/meteor/server/lib/oauth/getOAuthServices.ts @@ -0,0 +1,34 @@ +import { isTruthy } from '@rocket.chat/tools'; + +import { OAuthConfigs } from './oauthConfigs'; +import { type ICachedSettings } from '../../../app/settings/server/CachedSettings'; + +export const getOAuthServices = (settings: ICachedSettings) => { + const services = settings.getByRegexp(/^(Accounts_OAuth_|Accounts_OAuth_Custom-)[a-z0-9_]+$/i); + const filteredServices = services.filter(([, value]) => typeof value === 'boolean' && value === true); + return filteredServices + .map(([key, value]) => { + if (!value) { + return; + } + + let serviceName = key.replace('Accounts_OAuth_', ''); + if (serviceName === 'Meteor') { + serviceName = 'meteor-developer'; + } + + if (/Accounts_OAuth_Custom-/.test(key)) { + return; + } + + const serviceKey = serviceName.toLowerCase(); + + const oauthConfig = OAuthConfigs[serviceKey]; + if (!oauthConfig) { + return; + } + + return serviceKey; + }) + .filter(isTruthy); +}; diff --git a/apps/meteor/server/lib/oauth/oauthConfigs.ts b/apps/meteor/server/lib/oauth/oauthConfigs.ts new file mode 100644 index 0000000000000..b4c8fe6604fa5 --- /dev/null +++ b/apps/meteor/server/lib/oauth/oauthConfigs.ts @@ -0,0 +1,36 @@ +import type { Strategy } from 'passport'; +import { Strategy as FacebookStrategy } from 'passport-facebook'; +import { Strategy as GitHubStrategy } from 'passport-github2'; +import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; +import { Strategy as TwitterStrategy } from 'passport-twitter'; + +export type OAuthConfig = { + strategy: new (...args: any[]) => Strategy; + scope?: string[]; + includeEmail?: boolean; +}; + +export const OAuthConfigs: Record = { + github: { + strategy: GitHubStrategy, + scope: ['user:email'], + }, + facebook: { + strategy: FacebookStrategy, + scope: ['email'], + }, + google: { + strategy: GoogleStrategy, + scope: ['email', 'profile'], + }, + twitter: { + strategy: TwitterStrategy, + includeEmail: true, + }, + github_enterprise: { + strategy: GitHubStrategy, + scope: ['user:email'], + }, +} as const; + +export type Provider = keyof typeof OAuthConfigs; diff --git a/apps/meteor/server/lib/oauth/passportOAuthCallback.ts b/apps/meteor/server/lib/oauth/passportOAuthCallback.ts new file mode 100644 index 0000000000000..7bd128f6ebf7a --- /dev/null +++ b/apps/meteor/server/lib/oauth/passportOAuthCallback.ts @@ -0,0 +1,51 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import type { Request, Response } from 'express'; +import { Accounts } from 'meteor/accounts-base'; + +import { doesUserRequire2FA } from './twoFactorAuth'; + +const logger = new Logger('OAuth'); + +export const passportOAuthCallback = (siteUrl: string) => async (req: Request, res: Response) => { + const oAuthUser = req.user as IUser; + + if (!oAuthUser) { + return res.redirect('/login'); + } + + const { loginClient } = req.session; + + const secondFactorMethod = doesUserRequire2FA(oAuthUser); + + if (secondFactorMethod) { + const challengeId = await secondFactorMethod.sendTwoFactorChallenge(oAuthUser); + const twoFARedirectUrl = new URL(`/2fa/${secondFactorMethod.method}/${challengeId}`, siteUrl); + + if (loginClient) { + twoFARedirectUrl.searchParams.set('loginClient', loginClient); + } + + return res.redirect(twoFARedirectUrl.toString()); + } + + const stampedToken = Accounts._generateStampedLoginToken(); + await Accounts._insertLoginToken(oAuthUser._id, stampedToken); + + const redirectUrl = new URL(`/home`, siteUrl); + + redirectUrl.searchParams.set('resumeToken', stampedToken.token); + redirectUrl.searchParams.set('userId', oAuthUser._id); + + if (loginClient) { + redirectUrl.searchParams.set('loginClient', loginClient); + } + + setImmediate(() => res.redirect(redirectUrl.toString())); + + req.session.destroy((err) => { + if (err) { + logger.error({ msg: 'Error destroying OAuth session', err }); + } + }); +}; diff --git a/apps/meteor/server/lib/oauth/twoFactorAuth.ts b/apps/meteor/server/lib/oauth/twoFactorAuth.ts new file mode 100644 index 0000000000000..2c97ff5fc5244 --- /dev/null +++ b/apps/meteor/server/lib/oauth/twoFactorAuth.ts @@ -0,0 +1,37 @@ +import type { IUser } from '@rocket.chat/core-typings'; + +import { getRememberDate } from '../../../app/2fa/server/code'; +import { EmailCheckForOAuth } from '../../../app/2fa/server/code/EmailCheckForOAuth'; +import { TOTPCheckForOAuth } from '../../../app/2fa/server/code/TOTPCheckForOAuth'; + +export const emailCheckForOAuth = new EmailCheckForOAuth(); +export const totpCheckForOAuth = new TOTPCheckForOAuth(); + +const twoFACheckMethodsForOAuth = { + [emailCheckForOAuth.method]: emailCheckForOAuth, + [totpCheckForOAuth.method]: totpCheckForOAuth, +}; + +export const getTwoFAMethodForOAuth = (method: 'email' | 'totp') => { + return twoFACheckMethodsForOAuth[method]; +}; + +const getSecondFactorMethod = (user: IUser) => { + return Array.from(Object.values(twoFACheckMethodsForOAuth)).find((method) => method.isEnabled(user)); +}; + +export const doesUserRequire2FA = (user: IUser) => { + const rememberAfterRegistration = getRememberDate(user.createdAt); + + if (rememberAfterRegistration && rememberAfterRegistration > new Date()) { + return false; + } + + const secondFactorMethod = getSecondFactorMethod(user); + + if (!secondFactorMethod) { + return false; + } + + return secondFactorMethod; +}; diff --git a/apps/meteor/server/lib/oauth/updateOAuthServices.ts b/apps/meteor/server/lib/oauth/updateOAuthServices.ts index 784628cae44a9..f501b9c56888a 100644 --- a/apps/meteor/server/lib/oauth/updateOAuthServices.ts +++ b/apps/meteor/server/lib/oauth/updateOAuthServices.ts @@ -7,6 +7,7 @@ import type { } from '@rocket.chat/core-typings'; import { LoginServiceConfiguration } from '@rocket.chat/models'; +import { addPassportCustomOAuth } from './addPassportCustomOAuth'; import { logger } from './logger'; import { CustomOAuth } from '../../../app/custom-oauth/server/custom_oauth_server'; import { @@ -68,7 +69,7 @@ export async function updateOAuthServices(): Promise { data.rolesToSync = settings.get(`${key}-roles_to_sync`); data.showButton = settings.get(`${key}-show_button`); - new CustomOAuth(serviceKey, { + const config = { serverURL: data.serverURL, tokenPath: data.tokenPath, identityPath: data.identityPath, @@ -93,7 +94,12 @@ export async function updateOAuthServices(): Promise { rolesToSync: data.rolesToSync, accessTokenParam: data.accessTokenParam, showButton: data.showButton, - }); + clientSecret: data.secret, + clientId: data.clientId, + }; + + new CustomOAuth(serviceKey, config); + addPassportCustomOAuth(serviceKey, config); } if (serviceName === 'Facebook') { (data as FacebookOAuthConfiguration).appId = data.clientId as string; diff --git a/apps/meteor/server/lib/oauth/verifyFunction.ts b/apps/meteor/server/lib/oauth/verifyFunction.ts new file mode 100644 index 0000000000000..74d8c051e5992 --- /dev/null +++ b/apps/meteor/server/lib/oauth/verifyFunction.ts @@ -0,0 +1,40 @@ +import { Users } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import type { DoneCallback, Profile } from 'passport'; + +export const verifyFunction = async ( + accessToken: string, + refreshToken: string, + profile: Profile, + done: DoneCallback, + serviceName: string, +) => { + const profileWithRaw = profile as Profile & { _json?: Record; _raw?: string }; + const { _json, _raw, ...restProfile } = profileWithRaw; + + const user = await Accounts.updateOrCreateUserFromExternalService( + serviceName, + { + accessToken, + refreshToken, + name: profile.displayName, + email: profile?.emails?.[0]?.value, + ...profile, + ...restProfile, + ..._json, + }, + {}, + ); + + if (!user?.userId || typeof user?.userId !== 'string') { + return done(new Error('User not found')); + } + + const userFromDB = await Users.findOneById(user.userId); + + if (!userFromDB) { + return done(new Error('User not found')); + } + + return done(null, userFromDB); +}; diff --git a/apps/meteor/server/models.ts b/apps/meteor/server/models.ts index 49b5b98b19f08..1d8481923b987 100644 --- a/apps/meteor/server/models.ts +++ b/apps/meteor/server/models.ts @@ -75,6 +75,7 @@ import { WebdavAccountsRaw, WorkspaceCredentialsRaw, AbacAttributesRaw, + TwoFactorChallengesRaw, } from '@rocket.chat/models'; import type { Collection } from 'mongodb'; @@ -161,3 +162,4 @@ registerModel('IVideoConferenceModel', new VideoConferenceRaw(db)); registerModel('IWebdavAccountsModel', new WebdavAccountsRaw(db)); registerModel('IWorkspaceCredentialsModel', new WorkspaceCredentialsRaw(db)); registerModel('IAbacAttributesModel', new AbacAttributesRaw(db)); +registerModel('ITwoFactorChallengesModel', new TwoFactorChallengesRaw(db)); diff --git a/apps/meteor/server/settings/oauth.ts b/apps/meteor/server/settings/oauth.ts index 2aea9b119cf83..8ee583614882b 100644 --- a/apps/meteor/server/settings/oauth.ts +++ b/apps/meteor/server/settings/oauth.ts @@ -1,3 +1,5 @@ +import { Random } from '@rocket.chat/random'; + import { settingsRegistry } from '../../app/settings/server'; export const createOauthSettings = () => @@ -405,6 +407,11 @@ export const createOauthSettings = () => enableQuery, }); }); + await this.add('Accounts_OAuth_Session_Secret', Random.secret(), { + type: 'string', + secret: true, + hidden: true, + }); return this.section('Proxy', async function () { await this.add('Accounts_OAuth_Proxy_host', 'https://oauth-proxy.rocket.chat', { type: 'string', diff --git a/apps/meteor/tests/e2e/fixtures/addCustomOAuth.ts b/apps/meteor/tests/e2e/fixtures/addCustomOAuth.ts index 5721266b728d5..70885f1a634fb 100644 --- a/apps/meteor/tests/e2e/fixtures/addCustomOAuth.ts +++ b/apps/meteor/tests/e2e/fixtures/addCustomOAuth.ts @@ -15,4 +15,7 @@ export default async function addCustomOAuth(): Promise { await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test`, { data: { value: false }, headers }); await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-url`, { data: { value: 'https://rocket.chat' }, headers }); await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-login_style`, { data: { value: 'redirect' }, headers }); + await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-id`, { data: { value: 'abc' }, headers }); + await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-secret`, { data: { value: 'def' }, headers }); + await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-authorize_path`, { data: { value: '/authorize' }, headers }); } diff --git a/packages/core-typings/src/ILoginServiceConfiguration.ts b/packages/core-typings/src/ILoginServiceConfiguration.ts index 76a4779e806e9..c3fb05fa8e18f 100644 --- a/packages/core-typings/src/ILoginServiceConfiguration.ts +++ b/packages/core-typings/src/ILoginServiceConfiguration.ts @@ -34,6 +34,11 @@ export type OAuthConfiguration = { mergeRoles: boolean; rolesToSync: string; showButton: boolean; + addAutopublishFields?: { + forLoggedInUser: string[]; + forOtherUsers: string[]; + }; + pkce?: boolean; }; export type FacebookOAuthConfiguration = Omit, 'clientId'> & { diff --git a/packages/core-typings/src/ITwoFactorChallenge.ts b/packages/core-typings/src/ITwoFactorChallenge.ts new file mode 100644 index 0000000000000..783309001ee5d --- /dev/null +++ b/packages/core-typings/src/ITwoFactorChallenge.ts @@ -0,0 +1,7 @@ +export interface ITwoFactorChallenge { + _id: string; + userId: string; + method: 'email' | 'totp'; + expireAt: Date; + createdAt: Date; +} diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index 026a4130092c0..694954f1cdbf3 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -234,7 +234,7 @@ export interface IUser extends IRocketChatRecord { isOAuthUser?: boolean; // client only field __rooms?: string[]; inactiveReason?: 'deactivated' | 'pending_approval' | 'idle_too_long'; - + providerId?: string; abacAttributes?: IAbacAttributeDefinition[]; } diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index 90dd9c4e638a8..0b4cd9a471675 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -134,3 +134,5 @@ export type * from './ServerAudit/IAuditServerAbacAction'; export type * from './ServerAudit/IAuditUserChangedEvent'; export { schemas } from './Ajv'; + +export type * from './ITwoFactorChallenge'; diff --git a/packages/desktop-api/src/index.ts b/packages/desktop-api/src/index.ts index 9d10c1f89d1c5..5344ad5d18005 100644 --- a/packages/desktop-api/src/index.ts +++ b/packages/desktop-api/src/index.ts @@ -63,4 +63,5 @@ export interface IRocketChatDesktop { setUserToken: (token: string, userId: string) => void; openDocumentViewer: (url: string, format: string, options: any) => void; reloadServer: () => void; + openInBrowser: (url: string) => void; } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 06cf250932846..e05c1718ea03b 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1080,6 +1080,7 @@ "Categories": "Categories", "Categories*": "Categories*", "Certificates_and_Keys": "Certificates and Keys", + "Challenge_expired_please_try_again_later": "Challenge expired. Please try again later.", "Change_Room_Type": "Changing the Room Type", "Changed_from": "Changed from", "Changed_to": "Changed to", @@ -3412,6 +3413,7 @@ "Max_number_of_chats_per_agent_description": "The max. number of simultaneous chats that the agents can attend", "Max_number_of_uses": "Max number of uses", "Maximum": "Maximum", + "Maximum_number_of_attempts_reached_please_try_again_later": "Maximum number of attempts reached. Please try again later.", "Maximum_number_of_guests_reached": "Maximum number of guests reached", "Me": "Me", "Media": "Media", @@ -6824,6 +6826,7 @@ "registration.component.form.usernameAlreadyInUse": "Username already in use", "registration.component.form.usernameContainsInvalidChars": "Username contains invalid characters", "registration.component.login": "Login", + "registration.component.login.onWeb": "Login on web", "registration.component.login.incorrectPassword": "Incorrect password", "registration.component.login.userNotFound": "User not found", "registration.component.resetPassword": "Reset password", diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index 439447c49ea02..7e459803350b9 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -81,3 +81,4 @@ export type * from './updater'; export type * from './models/IWorkspaceCredentialsModel'; export type * from './models/ICallHistoryModel'; export type * from './models/IAbacAttributesModel'; +export type * from './models/ITwoFactorChallengesModel'; diff --git a/packages/model-typings/src/models/ITwoFactorChallengesModel.ts b/packages/model-typings/src/models/ITwoFactorChallengesModel.ts new file mode 100644 index 0000000000000..e0f523ae49104 --- /dev/null +++ b/packages/model-typings/src/models/ITwoFactorChallengesModel.ts @@ -0,0 +1,10 @@ +import type { ITwoFactorChallenge } from '@rocket.chat/core-typings'; +import type { DeleteResult, FindOptions } from 'mongodb'; + +import type { IBaseModel } from './IBaseModel'; + +export interface ITwoFactorChallengesModel extends IBaseModel { + findOneByPendingChallengeId(id: string, options?: FindOptions): Promise; + removeByPendingChallengeId(id: string): Promise; + createTwoFactorChallenge(userId: string, method: ITwoFactorChallenge['method']): Promise; +} diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 099420cd2f7ae..753e851d67a62 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -80,6 +80,7 @@ import type { IMediaCallNegotiationsModel, ICallHistoryModel, IAbacAttributesModel, + ITwoFactorChallengesModel, } from '@rocket.chat/model-typings'; import type { Collection, Db } from 'mongodb'; @@ -207,6 +208,7 @@ export const Migrations = proxify('IMigrationsModel'); export const ModerationReports = proxify('IModerationReportsModel'); export const WorkspaceCredentials = proxify('IWorkspaceCredentialsModel'); export const AbacAttributes = proxify('IAbacAttributesModel'); +export const TwoFactorChallenges = proxify('ITwoFactorChallengesModel'); export function registerServiceModels(db: Db, trash?: Collection>): void { registerModel('IUsersSessionsModel', () => new UsersSessionsRaw(db)); diff --git a/packages/models/src/modelClasses.ts b/packages/models/src/modelClasses.ts index c554fc9698550..b9d446e64c1ac 100644 --- a/packages/models/src/modelClasses.ts +++ b/packages/models/src/modelClasses.ts @@ -73,3 +73,4 @@ export * from './models/MediaCallNegotiations'; export * from './models/WorkspaceCredentials'; export * from './models/Trash'; export * from './models/CallHistory'; +export * from './models/TwoFactorChallenges'; diff --git a/packages/models/src/models/TwoFactorChallenges.ts b/packages/models/src/models/TwoFactorChallenges.ts new file mode 100644 index 0000000000000..2d4fb748c1d15 --- /dev/null +++ b/packages/models/src/models/TwoFactorChallenges.ts @@ -0,0 +1,38 @@ +import { randomBytes } from 'crypto'; + +import type { ITwoFactorChallenge } from '@rocket.chat/core-typings'; +import type { ITwoFactorChallengesModel } from '@rocket.chat/model-typings'; +import type { Db, FindOptions, IndexDescription } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; + +export class TwoFactorChallengesRaw extends BaseRaw implements ITwoFactorChallengesModel { + constructor(db: Db) { + super(db, 'two_factor_challenges'); + } + + override modelIndexes(): IndexDescription[] { + return [{ key: { expireAt: 1 }, expireAfterSeconds: 0 }]; + } + + findOneByPendingChallengeId(pendingChallengeId: string, options?: FindOptions) { + return this.findOne({ _id: pendingChallengeId }, options); + } + + removeByPendingChallengeId(pendingChallengeId: string) { + return this.deleteOne({ _id: pendingChallengeId }); + } + + async createTwoFactorChallenge(userId: string, method: ITwoFactorChallenge['method']): Promise { + const now = new Date(); + const challengeId = randomBytes(32).toString('hex'); + await this.insertOne({ + _id: challengeId, + userId, + method, + createdAt: now, + expireAt: new Date(now.getTime() + 1000 * 60 * 5), + }); + return challengeId; + } +} diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index 9c25e22e983f8..ef1f94da30eb1 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -41,6 +41,7 @@ import type { SettingsEndpoints } from './v1/settings'; import type { StatisticsEndpoints } from './v1/statistics'; import type { SubscriptionsEndpoints } from './v1/subscriptionsEndpoints'; import type { TeamsEndpoints } from './v1/teams'; +import type { TwoFactorChallengesEndpoints } from './v1/twoFactorChallenges'; import type { UsersEndpoints } from './v1/users'; import type { VideoConferenceEndpoints } from './v1/videoConference'; @@ -90,6 +91,7 @@ export interface Endpoints AuthEndpoints, ImportEndpoints, ServerEventsEndpoints, + TwoFactorChallengesEndpoints, DefaultEndpoints {} type OperationsByPathPatternAndMethod< @@ -264,6 +266,7 @@ export * from './v1/auth'; export * from './v1/cloud'; export * from './v1/banners'; export * from './default'; +export * from './v1/twoFactorChallenges'; // Export the ajv instance for use in other packages export * from './v1/Ajv'; diff --git a/packages/rest-typings/src/v1/twoFactorChallenges.ts b/packages/rest-typings/src/v1/twoFactorChallenges.ts new file mode 100644 index 0000000000000..42b471b244a48 --- /dev/null +++ b/packages/rest-typings/src/v1/twoFactorChallenges.ts @@ -0,0 +1,43 @@ +import { ajv } from './Ajv'; + +type TwoFactorChallengesSendEmailCode = { challengeId: string }; + +const TwoFactorChallengesSendEmailCodeSchema = { + type: 'object', + properties: { + challengeId: { + type: 'string', + }, + }, + required: ['challengeId'], + additionalProperties: false, +}; + +export const isTwoFactorChallengesSendEmailCodeParamsPOST = ajv.compile( + TwoFactorChallengesSendEmailCodeSchema, +); + +type TwoFactorChallengesVerifyChallenge = { challengeId: string; code: string }; + +const TwoFactorChallengesVerifyChallengeSchema = { + type: 'object', + properties: { + challengeId: { type: 'string' }, + code: { type: 'string' }, + }, + required: ['challengeId', 'code'], + additionalProperties: false, +}; + +export const isTwoFactorChallengesVerifyChallengeParamsPOST = ajv.compile( + TwoFactorChallengesVerifyChallengeSchema, +); + +export type TwoFactorChallengesEndpoints = { + '/v1/twoFactorChallenges.sendEmailCode': { + POST: (params: TwoFactorChallengesSendEmailCode) => void; + }; + '/v1/twoFactorChallenges.verifyChallenge': { + POST: (params: TwoFactorChallengesVerifyChallenge) => { loginToken: string; userId: string }; + }; +}; diff --git a/packages/web-ui-registration/global.d.ts b/packages/web-ui-registration/global.d.ts new file mode 100644 index 0000000000000..51c30e263ccad --- /dev/null +++ b/packages/web-ui-registration/global.d.ts @@ -0,0 +1,7 @@ +import type { IRocketChatDesktop } from '@rocket.chat/desktop-api'; + +declare global { + interface Window { + RocketChatDesktop?: IRocketChatDesktop; + } +} diff --git a/packages/web-ui-registration/src/LoginServices.tsx b/packages/web-ui-registration/src/LoginServices.tsx index e36c032e0436f..df490dfcd4d59 100644 --- a/packages/web-ui-registration/src/LoginServices.tsx +++ b/packages/web-ui-registration/src/LoginServices.tsx @@ -1,11 +1,13 @@ -import { ButtonGroup, Divider } from '@rocket.chat/fuselage'; +import { Button, ButtonGroup, Divider } from '@rocket.chat/fuselage'; import { useLoginServices, useSetting } from '@rocket.chat/ui-contexts'; -import type { Dispatch, ReactElement, SetStateAction } from 'react'; +import { useMemo, type Dispatch, type ReactElement, type SetStateAction } from 'react'; import { useTranslation } from 'react-i18next'; import type { LoginErrorState } from './LoginForm'; import LoginServicesButton from './LoginServicesButton'; +const servicesToBeShownOnDesktop = ['saml', 'cas', 'ldap']; + const LoginServices = ({ disabled, setError, @@ -17,10 +19,28 @@ const LoginServices = ({ const services = useLoginServices(); const showFormLogin = useSetting('Accounts_ShowFormLogin'); + const isDesktopApp = !!window.RocketChatDesktop?.openInBrowser; + + const servicesToShow = useMemo( + () => (isDesktopApp ? services.filter(({ service }) => servicesToBeShownOnDesktop.includes(service)) : services), + [isDesktopApp, services], + ); + if (services.length === 0) { return null; } + const handleLoginOnWeb = () => { + if (!isDesktopApp) { + return; + } + + const redirectUrl = new URL(window.location.href); + redirectUrl.searchParams.set('loginClient', 'desktop'); + + window.RocketChatDesktop?.openInBrowser(redirectUrl.toString()); + }; + return ( <> {showFormLogin && ( @@ -28,11 +48,20 @@ const LoginServices = ({ {t('registration.component.form.divider')} )} - - {services.map((service) => ( - - ))} - + + {servicesToShow.length > 0 && ( + + {servicesToShow.map((service) => ( + + ))} + + )} + + {isDesktopApp && ( + + )} ); }; diff --git a/packages/web-ui-registration/src/LoginServicesButton.tsx b/packages/web-ui-registration/src/LoginServicesButton.tsx index 7eb22ad5dbfc6..62d3d9856cd17 100644 --- a/packages/web-ui-registration/src/LoginServicesButton.tsx +++ b/packages/web-ui-registration/src/LoginServicesButton.tsx @@ -8,6 +8,8 @@ import { useTranslation } from 'react-i18next'; import type { LoginErrorState, LoginErrors } from './LoginForm'; +const servicesSupportedByMeteor = ['saml', 'cas', 'ldap']; + const LoginServicesButton = ({ buttonLabelText, icon, @@ -28,13 +30,28 @@ const LoginServicesButton = ({ const handler = useLoginWithService({ service, buttonLabelText, ...props }); const handleOnClick = useCallback(() => { + if (!servicesSupportedByMeteor.includes(service)) { + const url = new URL(window.location.href); + const queryParams = url.searchParams; + const loginClient = queryParams.get('loginClient'); + + const redirectUrl = new URL(`/oauth/${service}`, window.location.origin); + + if (loginClient) { + redirectUrl.searchParams.set('loginClient', loginClient); + } + + window.location.href = redirectUrl.toString(); + return; + } + handler().catch((e: { error?: LoginErrors; reason?: string }) => { if (!e.error || typeof e.error !== 'string') { return; } setError?.([e.error, e.reason]); }); - }, [handler, setError]); + }, [handler, setError, service]); return (