Skip to content

Commit

Permalink
fix: Timing out execution if MFA code is not provided
Browse files Browse the repository at this point in the history
Fixes #223
  • Loading branch information
Frank Steiler committed Jul 7, 2023
1 parent 7bb63d0 commit 81fdccf
Show file tree
Hide file tree
Showing 12 changed files with 232 additions and 12 deletions.
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
],
"preLaunchTask": "Build App",
"program": "${workspaceFolder}/app/src/main.ts",
"args": ["daemon"],
"args": ["token"],
"outFiles": [
"${workspaceFolder}/app/bin/**/*.js",
],
Expand Down
6 changes: 5 additions & 1 deletion app/src/app/error/codes/mfa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export const SERVER_ERR: ErrorStruct = buildErrorStruct(
name, prefix, `SERVER_ERR`, `HTTP Server Error`,
);

export const SERVER_TIMEOUT: ErrorStruct = buildErrorStruct(
name, prefix, `TIMEOUT`, `MFA server timeout (code needs to be provided within 10 minutes)`,
);

export const ADDR_IN_USE_ERR: ErrorStruct = buildErrorStruct(
name, prefix, `ADDR_IN_USE`, `HTTP Server could not start, because address/port is in use`,
);
Expand Down Expand Up @@ -52,7 +56,7 @@ export const NO_RESPONSE: ErrorStruct = buildErrorStruct(
);

export const TIMEOUT: ErrorStruct = buildErrorStruct(
name, prefix, `TIMEOUT`, `Timeout`,
name, prefix, `TIMEOUT`, `iCloud timeout`,
);

export const PRECONDITION_FAILED: ErrorStruct = buildErrorStruct(
Expand Down
3 changes: 2 additions & 1 deletion app/src/app/event/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {getLogger, logFilePath} from "../../lib/logger.js";
import {iCPSError} from "../error/error.js";
import {randomUUID} from "crypto";
import {EventHandler} from './event-handler.js';
import {AUTH_ERR, ERR_SIGINT, ERR_SIGTERM, LIBRARY_ERR, MFA_ERR} from '../error/error-codes.js';
import {AUTH_ERR, ERR_SIGINT, ERR_SIGTERM, MFA_ERR} from '../error/error-codes.js';
import {iCPSAppOptions} from '../factory.js';
import * as SYNC_ENGINE from '../../lib/sync-engine/constants.js';
import {SyncEngine} from '../../lib/sync-engine/sync-engine.js';
Expand All @@ -29,6 +29,7 @@ const reportDenyList = [
ERR_SIGINT.code,
ERR_SIGTERM.code,
MFA_ERR.ADDR_IN_USE_ERR.code, // Only happens if port/address is in use
MFA_ERR.SERVER_TIMEOUT.code, // Only happens if user does not interact within 10 minutes
// LIBRARY_ERR.LOCKED.code,
AUTH_ERR.UNAUTHORIZED.code, // Only happens if username/password don't match
];
Expand Down
27 changes: 25 additions & 2 deletions app/src/app/event/metrics-exporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import {iCloud} from '../../lib/icloud/icloud.js';
import * as ICLOUD from '../../lib/icloud/constants.js';
import * as SYNC_ENGINE from '../../lib/sync-engine/constants.js';
import * as ARCHIVE_ENGINE from '../../lib/archive-engine/constants.js';
import * as MFA_SERVER from '../../lib/icloud/mfa/constants.js';
import {SyncEngine} from '../../lib/sync-engine/sync-engine.js';
import {getLogger} from '../../lib/logger.js';
import {ArchiveEngine} from '../../lib/archive-engine/archive-engine.js';
import {ErrorHandler, ERROR_EVENT, WARN_EVENT} from './error-handler.js';
import * as fs from "fs";
import path from "path";
import {iCPSAppOptions} from "../factory.js";
import {MFAServer} from "../../lib/icloud/mfa/mfa-server.js";
import {DaemonAppEvents} from "../icloud-app.js";

/**
Expand Down Expand Up @@ -50,13 +52,15 @@ const FIELDS = {
"ERROR": `errors`,
"WARNING": `warnings`,
"STATUS_TIME": `status_time`,
"NEXT_SCHEDULE": `next_schedule`,
"STATUS": {
"name": `status`,
"values": {
"AUTHENTICATION_STARTED": `AUTHENTICATION_STARTED`,
"AUTHENTICATED": `AUTHENTICATED`,
"MFA_REQUIRED": `MFA_REQUIRED`,
"MFA_RECEIVED": `MFA_RECEIVED`,
"MFA_NOT_PROVIDED": `MFA_NOT_PROVIDED`,
"DEVICE_TRUSTED": `DEVICE_TRUSTED`,
"ACCOUNT_READY": `ACCOUNT_READY`,
"ICLOUD_READY": `ICLOUD_READY`,
Expand All @@ -74,6 +78,9 @@ const FIELDS = {
"SYNC_COMPLETED": `SYNC_COMPLETED`,
"SYNC_RETRY": `SYNC_RETRY`,
"ERROR": `ERROR`,
"SCHEDULED": `SCHEDULED`,
"SCHEDULED_SUCCESS": `SCHEDULED_SUCCESS`,
"SCHEDULED_FAILURE": `SCHEDULED_FAILURE`,
},
},
};
Expand Down Expand Up @@ -304,6 +311,10 @@ export class MetricsExporter implements EventHandler {
return;
}

if (obj instanceof MFAServer) {
this.handleMFAServer(obj);
return;
}

if (obj instanceof DaemonAppEvents) {
this.handleDaemonApp(obj);
Expand All @@ -320,7 +331,19 @@ export class MetricsExporter implements EventHandler {
}

/**
* Listens to iCloud events and provides CLI output
* Listens to MFA Server events and provides metrics output
* @param mfaServer - The MFA server to listen on
*/
private handleMFAServer(mfaServer: MFAServer) {
mfaServer.on(MFA_SERVER.EVENTS.MFA_NOT_PROVIDED, () => {
this.logDataPoint(new InfluxLineProtocolPoint()
.addField(FIELDS.STATUS_TIME, Date.now())
.addField(FIELDS.STATUS.name, FIELDS.STATUS.values.MFA_NOT_PROVIDED));
});
}

/**
* Listens to iCloud events and provides metrics output
* @param iCloud - The iCloud object to listen on
*/
private handleICloud(iCloud: iCloud) {
Expand Down Expand Up @@ -525,7 +548,7 @@ export class MetricsExporter implements EventHandler {
daemon.on(DaemonAppEvents.EVENTS.RETRY, (next: Date) => {
this.logDataPoint(new InfluxLineProtocolPoint()
.addField(FIELDS.STATUS_TIME, Date.now())
.addField(FIELDS.STATUS.name, FIELDS.STATUS.values.SCHEDULED_SUCCESS)
.addField(FIELDS.STATUS.name, FIELDS.STATUS.values.SCHEDULED_FAILURE)
.addField(FIELDS.NEXT_SCHEDULE, next.getTime()));
});
}
Expand Down
2 changes: 1 addition & 1 deletion app/src/app/icloud-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export class DaemonApp extends iCPSApp {
job?: Cron;

/**
* EventEmitter to notify EventHandlers
* EventEmitter to notify EventHandlers, needed because we cannot extend two classes
*/
event: DaemonAppEvents;

Expand Down
3 changes: 2 additions & 1 deletion app/src/lib/icloud/icloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,13 @@ export class iCloud extends EventEmitter {

/**
*
* @returns - A promise, that will resolve once this objects emits 'READY' or reject if it emits 'ERROR'
* @returns - A promise, that will resolve once this objects emits 'READY' or reject if it emits 'ERROR' or the MFA server times out
*/
getReady(): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.once(ICLOUD.EVENTS.READY, () => resolve());
this.once(ICLOUD.EVENTS.ERROR, err => reject(err));
this.mfaServer.once(MFA_SERVER.EVENTS.MFA_NOT_PROVIDED, err => reject(err));
});
}

Expand Down
3 changes: 2 additions & 1 deletion app/src/lib/icloud/mfa/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
*/
export enum EVENTS {
MFA_RECEIVED = `mfa_rec`,
MFA_RESEND = `mfa_resend`
MFA_RESEND = `mfa_resend`,
MFA_NOT_PROVIDED = `mfa_not_provided`
}

/**
Expand Down
30 changes: 29 additions & 1 deletion app/src/lib/icloud/mfa/mfa-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import {HANDLER_EVENT} from '../../../app/event/error-handler.js';
import {iCPSError} from '../../../app/error/error.js';
import {MFA_ERR} from '../../../app/error/error-codes.js';

/**
* The MFA timeout value in milliseconds
*/
export const MFA_TIMEOUT_VALUE = 1000 * 60 * 10; // 10 minutes

/**
* This objects starts a server, that will listen to incoming MFA codes and other MFA related commands
* todo - Implement re-request of MFA code
Expand All @@ -33,6 +38,11 @@ export class MFAServer extends EventEmitter {
*/
mfaMethod: MFAMethod;

/**
* Timer object to track timeout of MFA request
*/
mfaTimeout: NodeJS.Timeout;

/**
* Creates the server object
* @param port - The port to listen on, defaults to 80
Expand All @@ -44,7 +54,7 @@ export class MFAServer extends EventEmitter {
this.logger.debug(`Preparing MFA server on port ${this.port}`);
this.server = http.createServer(this.handleRequest.bind(this));
this.server.on(`error`, err => {
const icpsErr = Object.hasOwn(err, `code`) && (err as any).code === `EADDRINUSE`
const icpsErr = (Object.hasOwn(err, `code`) && (err as any).code === `EADDRINUSE`)
? new iCPSError(MFA_ERR.ADDR_IN_USE_ERR).addContext(`port`, this.port)
: new iCPSError(MFA_ERR.SERVER_ERR);

Expand All @@ -53,6 +63,14 @@ export class MFAServer extends EventEmitter {
this.emit(HANDLER_EVENT, icpsErr);
});

// Exiting application on MFA_NOT_PROVIDED
// this.on(MFA_SERVER.EVENTS.MFA_NOT_PROVIDED, () => {
// /* c8 ignore start */
// // Not testing process.exit
// process.exit(MFA_TIMEOUT);
// /* c8 ignore stop */
// });

// Default MFA request always goes to device
this.mfaMethod = new MFAMethod();
}
Expand All @@ -67,6 +85,12 @@ export class MFAServer extends EventEmitter {
this.logger.info(`Exposing endpoints: ${JSON.stringify(Object.values(MFA_SERVER.ENDPOINT))}`);
/* c8 ignore stop */
});

// MFA code needs to be provided within timeout period
this.mfaTimeout = setTimeout(() => {
this.emit(MFA_SERVER.EVENTS.MFA_NOT_PROVIDED, new iCPSError(MFA_ERR.SERVER_TIMEOUT));
this.stopServer();
}, MFA_TIMEOUT_VALUE);
}

/**
Expand Down Expand Up @@ -172,5 +196,9 @@ export class MFAServer extends EventEmitter {
this.server.close();
this.server = undefined;
}

if (this.mfaTimeout) {
clearTimeout(this.mfaTimeout);
}
}
}
51 changes: 50 additions & 1 deletion app/test/unit/icloud.mfa-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {MFAMethod} from '../../src/lib/icloud/mfa/mfa-method';
import * as PACKAGE from '../../src/lib/package';
import {mfaServerFactory, requestFactory, responseFactory} from '../_helpers/mfa-server.helper';
import {spyOnEvent} from '../_helpers/_general';
import {MFA_TIMEOUT_VALUE} from '../../src/lib/icloud/mfa/mfa-server';

describe(`MFA Code`, () => {
test(`Valid Code format`, () => {
Expand Down Expand Up @@ -143,7 +144,7 @@ describe(`MFA Resend`, () => {
"status": 403,
},
};
expect(mfaMethod.processResendError(error)).toEqual(new Error(`Timeout`));
expect(mfaMethod.processResendError(error)).toEqual(new Error(`iCloud timeout`));
});

test(`No response data`, () => {
Expand Down Expand Up @@ -297,12 +298,16 @@ describe(`Request routing`, () => {
});

describe(`Server lifecycle`, () => {
jest.useFakeTimers();

test(`Startup`, () => {
const server = mfaServerFactory();
server.server.listen = jest.fn() as any;

server.startServer();
expect((server.server.listen as any).mock.lastCall[0]).toEqual(80);
expect(server.mfaTimeout).toBeDefined();
clearTimeout(server.mfaTimeout);
});

test(`Shutdown`, () => {
Expand All @@ -322,4 +327,48 @@ describe(`Server lifecycle`, () => {
expect(res.writeHead).toHaveBeenCalledWith(200, {"Content-Type": `application/json`});
expect(res.end).toHaveBeenCalledWith(`{"message":"test"}`);
});

test(`Handle unknown server error`, () => {
const server = mfaServerFactory();
const handlerEvent = spyOnEvent(server, HANDLER_EVENT);
server.server.emit(`error`, new Error(`some server error`));

expect(handlerEvent).toHaveBeenCalledWith(new Error(`HTTP Server Error`));
});

test(`Handle address in use error`, () => {
const server = mfaServerFactory();
const handlerEvent = spyOnEvent(server, HANDLER_EVENT);
const error = new Error(`Address in use`);
(error as any).code = `EADDRINUSE`;
server.server.emit(`error`, new Error(`Address in use`));

expect(handlerEvent).toHaveBeenCalledWith(new Error(`HTTP Server Error`));
});

test(`Handle MFA timeout`, () => {
const server = mfaServerFactory();
server.server.listen = jest.fn() as any;
server.stopServer = jest.fn();
server.removeAllListeners(); // On listener event process.exit would be called

const timeoutEvent = spyOnEvent(server, EVENTS.MFA_NOT_PROVIDED);

server.startServer();

// Not called on start server
expect(timeoutEvent).not.toHaveBeenCalled();
expect(server.stopServer).not.toHaveBeenCalled();

// Advancing time slightly before timeout occurs
jest.advanceTimersByTime(MFA_TIMEOUT_VALUE - 1);
expect(timeoutEvent).not.toHaveBeenCalled();
expect(server.stopServer).not.toHaveBeenCalled();

// Timers should have been called now
jest.advanceTimersByTime(2);
expect(timeoutEvent).toHaveBeenCalledWith(new Error(`MFA server timeout (code needs to be provided within 10 minutes)`));
expect(server.stopServer).toHaveBeenCalled();
});
// Test(`Timeout`)
});
Loading

0 comments on commit 81fdccf

Please sign in to comment.