diff --git a/apps/meteor/app/2fa/server/code/TOTPCheck.ts b/apps/meteor/app/2fa/server/code/TOTPCheck.ts index 8e0a66f6e34..5f9c85676d4 100644 --- a/apps/meteor/app/2fa/server/code/TOTPCheck.ts +++ b/apps/meteor/app/2fa/server/code/TOTPCheck.ts @@ -20,6 +20,10 @@ export class TOTPCheck implements ICodeCheck { return false; } + if (!user.services?.totp?.secret) { + return false; + } + return TOTP.verify({ secret: user.services?.totp?.secret, token: code, diff --git a/apps/meteor/app/2fa/server/index.js b/apps/meteor/app/2fa/server/index.ts similarity index 100% rename from apps/meteor/app/2fa/server/index.js rename to apps/meteor/app/2fa/server/index.ts diff --git a/apps/meteor/app/2fa/server/lib/totp.js b/apps/meteor/app/2fa/server/lib/totp.ts similarity index 74% rename from apps/meteor/app/2fa/server/lib/totp.js rename to apps/meteor/app/2fa/server/lib/totp.ts index e662a5fde9f..ad831ce3702 100644 --- a/apps/meteor/app/2fa/server/lib/totp.js +++ b/apps/meteor/app/2fa/server/lib/totp.ts @@ -2,22 +2,23 @@ import { SHA256 } from 'meteor/sha'; import { Random } from 'meteor/random'; import speakeasy from 'speakeasy'; +// @ts-expect-error import { Users } from '../../../models'; import { settings } from '../../../settings/server'; export const TOTP = { - generateSecret() { + generateSecret(): speakeasy.GeneratedSecret { return speakeasy.generateSecret(); }, - generateOtpauthURL(secret, username) { + generateOtpauthURL(secret: speakeasy.GeneratedSecret, username: string): string { return speakeasy.otpauthURL({ secret: secret.ascii, label: `Rocket.Chat:${username}`, }); }, - verify({ secret, token, backupTokens, userId }) { + verify({ secret, token, backupTokens, userId }: { secret: string; token: string; backupTokens?: string[]; userId?: string }): boolean { // validates a backup code if (token.length === 8 && backupTokens) { const hashedCode = SHA256(token); @@ -34,7 +35,7 @@ export const TOTP = { return false; } - const maxDelta = settings.get('Accounts_TwoFactorAuthentication_MaxDelta'); + const maxDelta = settings.get('Accounts_TwoFactorAuthentication_MaxDelta'); if (maxDelta) { const verifiedDelta = speakeasy.totp.verifyDelta({ secret, @@ -53,7 +54,7 @@ export const TOTP = { }); }, - generateCodes() { + generateCodes(): { codes: string[]; hashedCodes: string[] } { // generate 12 backup codes const codes = []; const hashedCodes = []; diff --git a/apps/meteor/app/2fa/server/loginHandler.js b/apps/meteor/app/2fa/server/loginHandler.ts similarity index 62% rename from apps/meteor/app/2fa/server/loginHandler.js rename to apps/meteor/app/2fa/server/loginHandler.ts index 942abbf143e..cf17953cd94 100644 --- a/apps/meteor/app/2fa/server/loginHandler.js +++ b/apps/meteor/app/2fa/server/loginHandler.ts @@ -6,11 +6,20 @@ import { check } from 'meteor/check'; import { callbacks } from '../../../lib/callbacks'; import { checkCodeForUser } from './code/index'; +const isMeteorError = (error: any): error is Meteor.Error => { + return error?.meteorError !== undefined; +}; + +const isCredentialWithError = (credential: any): credential is { error: Error } => { + return credential?.error !== undefined; +}; + Accounts.registerLoginHandler('totp', function (options) { if (!options.totp || !options.totp.code) { return; } + // @ts-expect-error - not sure how to type this yet return Accounts._runLoginHandlers(this, options.totp.login); }); @@ -27,11 +36,15 @@ callbacks.add( return login; } + if (!login.user) { + return login; + } + const { totp } = loginArgs; checkCodeForUser({ user: login.user, - code: totp && totp.code, + code: totp?.code, options: { disablePasswordFallback: true }, }); @@ -41,24 +54,27 @@ callbacks.add( '2fa', ); -const recreateError = (errorDoc) => { - let error; +const copyTo = (from: T, to: T): T => { + Object.getOwnPropertyNames(to).forEach((key) => { + const idx: keyof T = key as keyof T; + to[idx] = from[idx]; + }); - if (errorDoc.meteorError) { - error = new Meteor.Error(); - delete errorDoc.meteorError; - } else { - error = new Error(); + return to; +}; + +const recreateError = (errorDoc: Error | Meteor.Error): Error | Meteor.Error => { + if (isMeteorError(errorDoc)) { + const error = new Meteor.Error(''); + return copyTo(errorDoc, error); } - Object.getOwnPropertyNames(errorDoc).forEach((key) => { - error[key] = errorDoc[key]; - }); - return error; + const error = new Error(); + return copyTo(errorDoc, error); }; -OAuth._retrievePendingCredential = function (key, ...args) { - const credentialSecret = args.length > 0 && args[0] !== undefined ? args[0] : null; +OAuth._retrievePendingCredential = function (key, ...args): string | Error | void { + const credentialSecret = args.length > 0 && args[0] !== undefined ? args[0] : undefined; check(key, String); const pendingCredential = OAuth._pendingCredentials.findOne({ @@ -70,7 +86,7 @@ OAuth._retrievePendingCredential = function (key, ...args) { return; } - if (pendingCredential.credential.error) { + if (isCredentialWithError(pendingCredential.credential)) { OAuth._pendingCredentials.remove({ _id: pendingCredential._id, }); diff --git a/apps/meteor/app/2fa/server/methods/checkCodesRemaining.js b/apps/meteor/app/2fa/server/methods/checkCodesRemaining.ts similarity index 75% rename from apps/meteor/app/2fa/server/methods/checkCodesRemaining.js rename to apps/meteor/app/2fa/server/methods/checkCodesRemaining.ts index 63222c87da7..b320b51751a 100644 --- a/apps/meteor/app/2fa/server/methods/checkCodesRemaining.js +++ b/apps/meteor/app/2fa/server/methods/checkCodesRemaining.ts @@ -8,6 +8,12 @@ Meteor.methods({ const user = Meteor.user(); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: '2fa:checkCodesRemaining', + }); + } + if (!user.services || !user.services.totp || !user.services.totp.enabled) { throw new Meteor.Error('invalid-totp'); } diff --git a/apps/meteor/app/2fa/server/methods/disable.js b/apps/meteor/app/2fa/server/methods/disable.ts similarity index 58% rename from apps/meteor/app/2fa/server/methods/disable.js rename to apps/meteor/app/2fa/server/methods/disable.ts index fe6e554305d..ab0f39753b4 100644 --- a/apps/meteor/app/2fa/server/methods/disable.js +++ b/apps/meteor/app/2fa/server/methods/disable.ts @@ -1,20 +1,27 @@ import { Meteor } from 'meteor/meteor'; -import { Users } from '../../../models'; +import { Users } from '../../../models/server'; import { TOTP } from '../lib/totp'; Meteor.methods({ '2fa:disable'(code) { - if (!Meteor.userId()) { + const userId = Meteor.userId(); + if (!userId) { throw new Meteor.Error('not-authorized'); } const user = Meteor.user(); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: '2fa:disable', + }); + } + const verified = TOTP.verify({ secret: user.services.totp.secret, token: code, - userId: Meteor.userId(), + userId, backupTokens: user.services.totp.hashedBackup, }); @@ -22,6 +29,6 @@ Meteor.methods({ return false; } - return Users.disable2FAByUserId(Meteor.userId()); + return Users.disable2FAByUserId(userId); }, }); diff --git a/apps/meteor/app/2fa/server/methods/enable.js b/apps/meteor/app/2fa/server/methods/enable.ts similarity index 53% rename from apps/meteor/app/2fa/server/methods/enable.js rename to apps/meteor/app/2fa/server/methods/enable.ts index ef34662436e..3c26effb380 100644 --- a/apps/meteor/app/2fa/server/methods/enable.js +++ b/apps/meteor/app/2fa/server/methods/enable.ts @@ -1,19 +1,26 @@ import { Meteor } from 'meteor/meteor'; -import { Users } from '../../../models'; +import { Users } from '../../../models/server'; import { TOTP } from '../lib/totp'; Meteor.methods({ '2fa:enable'() { - if (!Meteor.userId()) { + const userId = Meteor.userId(); + if (!userId) { throw new Meteor.Error('not-authorized'); } const user = Meteor.user(); + if (!user || !user.username) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: '2fa:enable', + }); + } + const secret = TOTP.generateSecret(); - Users.disable2FAAndSetTempSecretByUserId(Meteor.userId(), secret.base32); + Users.disable2FAAndSetTempSecretByUserId(userId, secret.base32); return { secret: secret.base32, diff --git a/apps/meteor/app/2fa/server/methods/regenerateCodes.js b/apps/meteor/app/2fa/server/methods/regenerateCodes.ts similarity index 73% rename from apps/meteor/app/2fa/server/methods/regenerateCodes.js rename to apps/meteor/app/2fa/server/methods/regenerateCodes.ts index bfdc8d955d7..c3a37657529 100644 --- a/apps/meteor/app/2fa/server/methods/regenerateCodes.js +++ b/apps/meteor/app/2fa/server/methods/regenerateCodes.ts @@ -1,15 +1,21 @@ import { Meteor } from 'meteor/meteor'; -import { Users } from '../../../models'; +import { Users } from '../../../models/server'; import { TOTP } from '../lib/totp'; Meteor.methods({ '2fa:regenerateCodes'(userToken) { - if (!Meteor.userId()) { + const userId = Meteor.userId(); + if (!userId) { throw new Meteor.Error('not-authorized'); } const user = Meteor.user(); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: '2fa:regenerateCodes', + }); + } if (!user.services || !user.services.totp || !user.services.totp.enabled) { throw new Meteor.Error('invalid-totp'); @@ -18,7 +24,7 @@ Meteor.methods({ const verified = TOTP.verify({ secret: user.services.totp.secret, token: userToken, - userId: Meteor.userId(), + userId, backupTokens: user.services.totp.hashedBackup, }); diff --git a/apps/meteor/app/2fa/server/methods/validateTempToken.js b/apps/meteor/app/2fa/server/methods/validateTempToken.ts similarity index 74% rename from apps/meteor/app/2fa/server/methods/validateTempToken.js rename to apps/meteor/app/2fa/server/methods/validateTempToken.ts index 71565b0d42e..13429ca5d5b 100644 --- a/apps/meteor/app/2fa/server/methods/validateTempToken.js +++ b/apps/meteor/app/2fa/server/methods/validateTempToken.ts @@ -1,15 +1,21 @@ import { Meteor } from 'meteor/meteor'; -import { Users } from '../../../models'; +import { Users } from '../../../models/server'; import { TOTP } from '../lib/totp'; Meteor.methods({ '2fa:validateTempToken'(userToken) { - if (!Meteor.userId()) { + const userId = Meteor.userId(); + if (!userId) { throw new Meteor.Error('not-authorized'); } const user = Meteor.user(); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: '2fa:validateTempToken', + }); + } if (!user.services || !user.services.totp || !user.services.totp.tempSecret) { throw new Meteor.Error('invalid-totp'); diff --git a/apps/meteor/app/authentication/server/ILoginAttempt.ts b/apps/meteor/app/authentication/server/ILoginAttempt.ts index 4fc5498dd7b..f48aeba7d07 100644 --- a/apps/meteor/app/authentication/server/ILoginAttempt.ts +++ b/apps/meteor/app/authentication/server/ILoginAttempt.ts @@ -7,6 +7,12 @@ interface IMethodArgument { algorithm: string; }; resume?: string; + + cas?: boolean; + + totp?: { + code: string; + }; } export interface ILoginAttempt { diff --git a/apps/meteor/definition/externals/meteor/oauth.d.ts b/apps/meteor/definition/externals/meteor/oauth.d.ts index 67780bcbdb3..fa952d236b8 100644 --- a/apps/meteor/definition/externals/meteor/oauth.d.ts +++ b/apps/meteor/definition/externals/meteor/oauth.d.ts @@ -1,7 +1,23 @@ declare module 'meteor/oauth' { + import { Mongo } from 'meteor/mongo'; + import { IRocketChatRecord } from '@rocket.chat/core-typings'; + + interface IOauthCredentials extends IRocketChatRecord { + key: string; + credentialSecret: string; + credential: + | { + error: Error; + } + | string; + } + namespace OAuth { function _redirectUri(serviceName: string, config: any, params: any, absoluteUrlOptions: any): string; function _retrieveCredentialSecret(credentialToken: string): string | null; + function _retrievePendingCredential(key: string, ...args: string[]): void; + function openSecret(secret: string): string; const _storageTokenPrefix: string; + const _pendingCredentials: Mongo.Collection; } } diff --git a/apps/meteor/lib/callbacks.ts b/apps/meteor/lib/callbacks.ts index cee171c6260..cef506ef22b 100644 --- a/apps/meteor/lib/callbacks.ts +++ b/apps/meteor/lib/callbacks.ts @@ -14,6 +14,7 @@ import type { import type { Logger } from '../app/logger/server'; import type { IBusinessHourBehavior } from '../app/livechat/server/business-hour/AbstractBusinessHour'; import { getRandomId } from './random'; +import { ILoginAttempt } from '../app/authentication/server/ILoginAttempt'; enum CallbackPriority { HIGH = -1000, @@ -54,6 +55,7 @@ type EventLikeCallbackSignatures = { 'beforeJoinDefaultChannels': (user: IUser) => void; 'beforeCreateChannel': (owner: IUser, room: IRoom) => void; 'afterCreateRoom': (owner: IUser, room: IRoom) => void; + 'onValidateLogin': (login: ILoginAttempt) => void; }; /** diff --git a/apps/meteor/package.json b/apps/meteor/package.json index e6c943460fb..7f3ade827db 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -215,6 +215,7 @@ "@types/lodash": "^4.14.177", "@types/lodash.debounce": "^4.0.6", "@types/proxy-from-env": "^1.0.1", + "@types/speakeasy": "^2.0.7", "adm-zip": "0.5.9", "agenda": "https://github.com/RocketChat/agenda#c2cfcc532b8409561104dca980e6adbbcbdf5442", "ajv": "^8.7.1", diff --git a/yarn.lock b/yarn.lock index 8b99a9254e2..263e49e208f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4037,6 +4037,7 @@ __metadata: "@types/rewire": ^2.5.28 "@types/semver": ^7.3.6 "@types/sharp": ^0.29.4 + "@types/speakeasy": ^2.0.7 "@types/string-strip-html": ^5.0.0 "@types/supertest": ^2.0.11 "@types/toastr": ^2.1.39 @@ -6984,6 +6985,15 @@ __metadata: languageName: node linkType: hard +"@types/speakeasy@npm:^2.0.7": + version: 2.0.7 + resolution: "@types/speakeasy@npm:2.0.7" + dependencies: + "@types/node": "*" + checksum: 30152d950ea23654060ef596ea459935a9ea80ba4d9803b13fc9b02c7a27a7b5c96742f2cb00db51b19ba0e13ef9a16c1fd977042f61c9019b10c4191e2f1b97 + languageName: node + linkType: hard + "@types/stack-utils@npm:^2.0.0": version: 2.0.1 resolution: "@types/stack-utils@npm:2.0.1"