Skip to content
Snippets Groups Projects
Unverified Commit d4f4e550 authored by Kevin Aleman's avatar Kevin Aleman Committed by GitHub
Browse files

Chore: Rewrite 2fa to typescript (#25285)

parent c731037a
No related branches found
No related tags found
No related merge requests found
Showing
with 120 additions and 32 deletions
...@@ -20,6 +20,10 @@ export class TOTPCheck implements ICodeCheck { ...@@ -20,6 +20,10 @@ export class TOTPCheck implements ICodeCheck {
return false; return false;
} }
if (!user.services?.totp?.secret) {
return false;
}
return TOTP.verify({ return TOTP.verify({
secret: user.services?.totp?.secret, secret: user.services?.totp?.secret,
token: code, token: code,
......
...@@ -2,22 +2,23 @@ import { SHA256 } from 'meteor/sha'; ...@@ -2,22 +2,23 @@ import { SHA256 } from 'meteor/sha';
import { Random } from 'meteor/random'; import { Random } from 'meteor/random';
import speakeasy from 'speakeasy'; import speakeasy from 'speakeasy';
// @ts-expect-error
import { Users } from '../../../models'; import { Users } from '../../../models';
import { settings } from '../../../settings/server'; import { settings } from '../../../settings/server';
export const TOTP = { export const TOTP = {
generateSecret() { generateSecret(): speakeasy.GeneratedSecret {
return speakeasy.generateSecret(); return speakeasy.generateSecret();
}, },
generateOtpauthURL(secret, username) { generateOtpauthURL(secret: speakeasy.GeneratedSecret, username: string): string {
return speakeasy.otpauthURL({ return speakeasy.otpauthURL({
secret: secret.ascii, secret: secret.ascii,
label: `Rocket.Chat:${username}`, 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 // validates a backup code
if (token.length === 8 && backupTokens) { if (token.length === 8 && backupTokens) {
const hashedCode = SHA256(token); const hashedCode = SHA256(token);
...@@ -34,7 +35,7 @@ export const TOTP = { ...@@ -34,7 +35,7 @@ export const TOTP = {
return false; return false;
} }
const maxDelta = settings.get('Accounts_TwoFactorAuthentication_MaxDelta'); const maxDelta = settings.get<number>('Accounts_TwoFactorAuthentication_MaxDelta');
if (maxDelta) { if (maxDelta) {
const verifiedDelta = speakeasy.totp.verifyDelta({ const verifiedDelta = speakeasy.totp.verifyDelta({
secret, secret,
...@@ -53,7 +54,7 @@ export const TOTP = { ...@@ -53,7 +54,7 @@ export const TOTP = {
}); });
}, },
generateCodes() { generateCodes(): { codes: string[]; hashedCodes: string[] } {
// generate 12 backup codes // generate 12 backup codes
const codes = []; const codes = [];
const hashedCodes = []; const hashedCodes = [];
......
...@@ -6,11 +6,20 @@ import { check } from 'meteor/check'; ...@@ -6,11 +6,20 @@ import { check } from 'meteor/check';
import { callbacks } from '../../../lib/callbacks'; import { callbacks } from '../../../lib/callbacks';
import { checkCodeForUser } from './code/index'; 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) { Accounts.registerLoginHandler('totp', function (options) {
if (!options.totp || !options.totp.code) { if (!options.totp || !options.totp.code) {
return; return;
} }
// @ts-expect-error - not sure how to type this yet
return Accounts._runLoginHandlers(this, options.totp.login); return Accounts._runLoginHandlers(this, options.totp.login);
}); });
...@@ -27,11 +36,15 @@ callbacks.add( ...@@ -27,11 +36,15 @@ callbacks.add(
return login; return login;
} }
if (!login.user) {
return login;
}
const { totp } = loginArgs; const { totp } = loginArgs;
checkCodeForUser({ checkCodeForUser({
user: login.user, user: login.user,
code: totp && totp.code, code: totp?.code,
options: { disablePasswordFallback: true }, options: { disablePasswordFallback: true },
}); });
...@@ -41,24 +54,27 @@ callbacks.add( ...@@ -41,24 +54,27 @@ callbacks.add(
'2fa', '2fa',
); );
const recreateError = (errorDoc) => { const copyTo = <T extends Error>(from: T, to: T): T => {
let error; Object.getOwnPropertyNames(to).forEach((key) => {
const idx: keyof T = key as keyof T;
to[idx] = from[idx];
});
if (errorDoc.meteorError) { return to;
error = new Meteor.Error(); };
delete errorDoc.meteorError;
} else { const recreateError = (errorDoc: Error | Meteor.Error): Error | Meteor.Error => {
error = new Error(); if (isMeteorError(errorDoc)) {
const error = new Meteor.Error('');
return copyTo(errorDoc, error);
} }
Object.getOwnPropertyNames(errorDoc).forEach((key) => { const error = new Error();
error[key] = errorDoc[key]; return copyTo(errorDoc, error);
});
return error;
}; };
OAuth._retrievePendingCredential = function (key, ...args) { OAuth._retrievePendingCredential = function (key, ...args): string | Error | void {
const credentialSecret = args.length > 0 && args[0] !== undefined ? args[0] : null; const credentialSecret = args.length > 0 && args[0] !== undefined ? args[0] : undefined;
check(key, String); check(key, String);
const pendingCredential = OAuth._pendingCredentials.findOne({ const pendingCredential = OAuth._pendingCredentials.findOne({
...@@ -70,7 +86,7 @@ OAuth._retrievePendingCredential = function (key, ...args) { ...@@ -70,7 +86,7 @@ OAuth._retrievePendingCredential = function (key, ...args) {
return; return;
} }
if (pendingCredential.credential.error) { if (isCredentialWithError(pendingCredential.credential)) {
OAuth._pendingCredentials.remove({ OAuth._pendingCredentials.remove({
_id: pendingCredential._id, _id: pendingCredential._id,
}); });
......
...@@ -8,6 +8,12 @@ Meteor.methods({ ...@@ -8,6 +8,12 @@ Meteor.methods({
const user = Meteor.user(); 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) { if (!user.services || !user.services.totp || !user.services.totp.enabled) {
throw new Meteor.Error('invalid-totp'); throw new Meteor.Error('invalid-totp');
} }
......
import { Meteor } from 'meteor/meteor'; import { Meteor } from 'meteor/meteor';
import { Users } from '../../../models'; import { Users } from '../../../models/server';
import { TOTP } from '../lib/totp'; import { TOTP } from '../lib/totp';
Meteor.methods({ Meteor.methods({
'2fa:disable'(code) { '2fa:disable'(code) {
if (!Meteor.userId()) { const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('not-authorized'); throw new Meteor.Error('not-authorized');
} }
const user = Meteor.user(); const user = Meteor.user();
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: '2fa:disable',
});
}
const verified = TOTP.verify({ const verified = TOTP.verify({
secret: user.services.totp.secret, secret: user.services.totp.secret,
token: code, token: code,
userId: Meteor.userId(), userId,
backupTokens: user.services.totp.hashedBackup, backupTokens: user.services.totp.hashedBackup,
}); });
...@@ -22,6 +29,6 @@ Meteor.methods({ ...@@ -22,6 +29,6 @@ Meteor.methods({
return false; return false;
} }
return Users.disable2FAByUserId(Meteor.userId()); return Users.disable2FAByUserId(userId);
}, },
}); });
import { Meteor } from 'meteor/meteor'; import { Meteor } from 'meteor/meteor';
import { Users } from '../../../models'; import { Users } from '../../../models/server';
import { TOTP } from '../lib/totp'; import { TOTP } from '../lib/totp';
Meteor.methods({ Meteor.methods({
'2fa:enable'() { '2fa:enable'() {
if (!Meteor.userId()) { const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('not-authorized'); throw new Meteor.Error('not-authorized');
} }
const user = Meteor.user(); const user = Meteor.user();
if (!user || !user.username) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: '2fa:enable',
});
}
const secret = TOTP.generateSecret(); const secret = TOTP.generateSecret();
Users.disable2FAAndSetTempSecretByUserId(Meteor.userId(), secret.base32); Users.disable2FAAndSetTempSecretByUserId(userId, secret.base32);
return { return {
secret: secret.base32, secret: secret.base32,
......
import { Meteor } from 'meteor/meteor'; import { Meteor } from 'meteor/meteor';
import { Users } from '../../../models'; import { Users } from '../../../models/server';
import { TOTP } from '../lib/totp'; import { TOTP } from '../lib/totp';
Meteor.methods({ Meteor.methods({
'2fa:regenerateCodes'(userToken) { '2fa:regenerateCodes'(userToken) {
if (!Meteor.userId()) { const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('not-authorized'); throw new Meteor.Error('not-authorized');
} }
const user = Meteor.user(); 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) { if (!user.services || !user.services.totp || !user.services.totp.enabled) {
throw new Meteor.Error('invalid-totp'); throw new Meteor.Error('invalid-totp');
...@@ -18,7 +24,7 @@ Meteor.methods({ ...@@ -18,7 +24,7 @@ Meteor.methods({
const verified = TOTP.verify({ const verified = TOTP.verify({
secret: user.services.totp.secret, secret: user.services.totp.secret,
token: userToken, token: userToken,
userId: Meteor.userId(), userId,
backupTokens: user.services.totp.hashedBackup, backupTokens: user.services.totp.hashedBackup,
}); });
......
import { Meteor } from 'meteor/meteor'; import { Meteor } from 'meteor/meteor';
import { Users } from '../../../models'; import { Users } from '../../../models/server';
import { TOTP } from '../lib/totp'; import { TOTP } from '../lib/totp';
Meteor.methods({ Meteor.methods({
'2fa:validateTempToken'(userToken) { '2fa:validateTempToken'(userToken) {
if (!Meteor.userId()) { const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('not-authorized'); throw new Meteor.Error('not-authorized');
} }
const user = Meteor.user(); 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) { if (!user.services || !user.services.totp || !user.services.totp.tempSecret) {
throw new Meteor.Error('invalid-totp'); throw new Meteor.Error('invalid-totp');
......
...@@ -7,6 +7,12 @@ interface IMethodArgument { ...@@ -7,6 +7,12 @@ interface IMethodArgument {
algorithm: string; algorithm: string;
}; };
resume?: string; resume?: string;
cas?: boolean;
totp?: {
code: string;
};
} }
export interface ILoginAttempt { export interface ILoginAttempt {
......
declare module 'meteor/oauth' { 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 { namespace OAuth {
function _redirectUri(serviceName: string, config: any, params: any, absoluteUrlOptions: any): string; function _redirectUri(serviceName: string, config: any, params: any, absoluteUrlOptions: any): string;
function _retrieveCredentialSecret(credentialToken: string): string | null; function _retrieveCredentialSecret(credentialToken: string): string | null;
function _retrievePendingCredential(key: string, ...args: string[]): void;
function openSecret(secret: string): string;
const _storageTokenPrefix: string; const _storageTokenPrefix: string;
const _pendingCredentials: Mongo.Collection<IOauthCredentials>;
} }
} }
...@@ -14,6 +14,7 @@ import type { ...@@ -14,6 +14,7 @@ import type {
import type { Logger } from '../app/logger/server'; import type { Logger } from '../app/logger/server';
import type { IBusinessHourBehavior } from '../app/livechat/server/business-hour/AbstractBusinessHour'; import type { IBusinessHourBehavior } from '../app/livechat/server/business-hour/AbstractBusinessHour';
import { getRandomId } from './random'; import { getRandomId } from './random';
import { ILoginAttempt } from '../app/authentication/server/ILoginAttempt';
enum CallbackPriority { enum CallbackPriority {
HIGH = -1000, HIGH = -1000,
...@@ -54,6 +55,7 @@ type EventLikeCallbackSignatures = { ...@@ -54,6 +55,7 @@ type EventLikeCallbackSignatures = {
'beforeJoinDefaultChannels': (user: IUser) => void; 'beforeJoinDefaultChannels': (user: IUser) => void;
'beforeCreateChannel': (owner: IUser, room: IRoom) => void; 'beforeCreateChannel': (owner: IUser, room: IRoom) => void;
'afterCreateRoom': (owner: IUser, room: IRoom) => void; 'afterCreateRoom': (owner: IUser, room: IRoom) => void;
'onValidateLogin': (login: ILoginAttempt) => void;
}; };
/** /**
......
...@@ -215,6 +215,7 @@ ...@@ -215,6 +215,7 @@
"@types/lodash": "^4.14.177", "@types/lodash": "^4.14.177",
"@types/lodash.debounce": "^4.0.6", "@types/lodash.debounce": "^4.0.6",
"@types/proxy-from-env": "^1.0.1", "@types/proxy-from-env": "^1.0.1",
"@types/speakeasy": "^2.0.7",
"adm-zip": "0.5.9", "adm-zip": "0.5.9",
"agenda": "https://github.com/RocketChat/agenda#c2cfcc532b8409561104dca980e6adbbcbdf5442", "agenda": "https://github.com/RocketChat/agenda#c2cfcc532b8409561104dca980e6adbbcbdf5442",
"ajv": "^8.7.1", "ajv": "^8.7.1",
......
...@@ -4037,6 +4037,7 @@ __metadata: ...@@ -4037,6 +4037,7 @@ __metadata:
"@types/rewire": ^2.5.28 "@types/rewire": ^2.5.28
"@types/semver": ^7.3.6 "@types/semver": ^7.3.6
"@types/sharp": ^0.29.4 "@types/sharp": ^0.29.4
"@types/speakeasy": ^2.0.7
"@types/string-strip-html": ^5.0.0 "@types/string-strip-html": ^5.0.0
"@types/supertest": ^2.0.11 "@types/supertest": ^2.0.11
"@types/toastr": ^2.1.39 "@types/toastr": ^2.1.39
...@@ -6984,6 +6985,15 @@ __metadata: ...@@ -6984,6 +6985,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@types/stack-utils@npm:^2.0.0":
version: 2.0.1 version: 2.0.1
resolution: "@types/stack-utils@npm:2.0.1" resolution: "@types/stack-utils@npm:2.0.1"
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment