Skip to content

Commit

Permalink
Chore: Rewrite 2fa to typescript (#25285)
Browse files Browse the repository at this point in the history
  • Loading branch information
KevLehman authored May 11, 2022
1 parent c731037 commit d4f4e55
Show file tree
Hide file tree
Showing 14 changed files with 120 additions and 32 deletions.
4 changes: 4 additions & 0 deletions apps/meteor/app/2fa/server/code/TOTPCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -34,7 +35,7 @@ export const TOTP = {
return false;
}

const maxDelta = settings.get('Accounts_TwoFactorAuthentication_MaxDelta');
const maxDelta = settings.get<number>('Accounts_TwoFactorAuthentication_MaxDelta');
if (maxDelta) {
const verifiedDelta = speakeasy.totp.verifyDelta({
secret,
Expand All @@ -53,7 +54,7 @@ export const TOTP = {
});
},

generateCodes() {
generateCodes(): { codes: string[]; hashedCodes: string[] } {
// generate 12 backup codes
const codes = [];
const hashedCodes = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -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 },
});

Expand All @@ -41,24 +54,27 @@ callbacks.add(
'2fa',
);

const recreateError = (errorDoc) => {
let error;
const copyTo = <T extends Error>(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({
Expand All @@ -70,7 +86,7 @@ OAuth._retrievePendingCredential = function (key, ...args) {
return;
}

if (pendingCredential.credential.error) {
if (isCredentialWithError(pendingCredential.credential)) {
OAuth._pendingCredentials.remove({
_id: pendingCredential._id,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,34 @@
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,
});

if (!verified) {
return false;
}

return Users.disable2FAByUserId(Meteor.userId());
return Users.disable2FAByUserId(userId);
},
});
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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,
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
6 changes: 6 additions & 0 deletions apps/meteor/app/authentication/server/ILoginAttempt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ interface IMethodArgument {
algorithm: string;
};
resume?: string;

cas?: boolean;

totp?: {
code: string;
};
}

export interface ILoginAttempt {
Expand Down
16 changes: 16 additions & 0 deletions apps/meteor/definition/externals/meteor/oauth.d.ts
Original file line number Diff line number Diff line change
@@ -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<IOauthCredentials>;
}
}
2 changes: 2 additions & 0 deletions apps/meteor/lib/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
};

/**
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit d4f4e55

Please sign in to comment.