Skip to content

Commit

Permalink
feat(api-v3): add apiKey command and pass API key to protected services
Browse files Browse the repository at this point in the history
On July 18th, 2019, Troy Hunt moved the haveibeenpwned.com API to v3 and protected several services
behind authentication, requiring an API key. See his blog post here:
https://www.troyhunt.com/authentication-and-the-have-i-been-pwned-api/

BREAKING CHANGE: Several of the haveibeenpwned.com services now require an API key from
https://haveibeenpwned.com/API/Key. See [Troy's blog
post](https://www.troyhunt.com/authentication-and-the-have-i-been-pwned-api/) for rationale and a
full explanation. Once you have obtained your API key, you must run "pwned apiKey <your-key>" to
configure `pwned` so that it may use it in future requests made to protected endpoints. Some
commands, like `pw`, do not require an API key and will continue to function without one. Thus, you
may continue to use `pwned` without an API key, as long as you only care about the commands that do
not require one. Each command indicates if it requires an API key in the help for the specific
command.
  • Loading branch information
wKovacs64 committed Jul 19, 2019
1 parent ae2f30e commit 31ff873
Show file tree
Hide file tree
Showing 9 changed files with 228 additions and 12 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@
"node": ">= 8"
},
"dependencies": {
"common-tags": "^1.8.0",
"conf": "^5.0.0",
"hibp": "^8.0.0",
"ora": "^3.4.0",
"prettyjson": "^1.2.1",
Expand All @@ -76,6 +78,7 @@
"@babel/preset-typescript": "7.3.3",
"@commitlint/cli": "8.1.0",
"@commitlint/config-conventional": "8.1.0",
"@types/common-tags": "1.8.0",
"@types/jest": "24.0.15",
"@types/node": "12.6.8",
"@types/ora": "3.2.0",
Expand Down
60 changes: 60 additions & 0 deletions src/commands/apiKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Argv, CommandBuilder } from 'yargs';
import { oneLine } from 'common-tags';
import config from '../utils/config';
import logger from '../utils/logger';

export const command = 'apiKey <key>';
export const describe = 'set the API key to be used for authenticated requests';

interface ApiKeyArgvOptions {
key: string;
}

interface ApiKeyHandlerOptions {
key: string;
}

export const builder: CommandBuilder<
ApiKeyArgvOptions,
ApiKeyHandlerOptions
> /* istanbul ignore next */ = (yargs): Argv<ApiKeyHandlerOptions> =>
yargs
.positional('key', { type: 'string' })
.demandOption('key')
.check(argv => {
if (!argv.key.length) {
throw new Error('The key argument must not be empty.');
}
return true;
})
.group(['h', 'v'], 'Global Options:').epilog(oneLine`
Please obtain an API key from https://haveibeenpwned.com/API/Key and then
run "pwned apiKey <key>" to configure pwned.
`);

/**
* Stores the user's specified API key to be used for future requests to
* authenticated endpoints.
*
* @param {object} argv the parsed argv object
* @param {string} argv.key the user's API key
* @returns {Promise<void>} the resulting Promise where output is rendered
*/
export const handler = async ({ key }: ApiKeyHandlerOptions): Promise<void> => {
try {
config.set('apiKey', key);
if (config.get('apiKey') === key) {
logger.log(oneLine`
✔ API key saved successfully. It will be used in future requests made
to haveibeenpwned.com services that require authentication.
`);
} else {
throw new Error(oneLine`
✖ API key mismatch: the key read back from config does not match the
key supplied!
`);
}
} catch (err) {
logger.error(err.message);
}
};
14 changes: 11 additions & 3 deletions src/commands/ba.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Argv, CommandBuilder } from 'yargs';
import { breachedAccount } from 'hibp';
import prettyjson from 'prettyjson';
import { oneLine } from 'common-tags';
import config from '../utils/config';
import translateApiError from '../utils/translateApiError';
import logger from '../utils/logger';
import spinner from '../utils/spinner';
import userAgent from '../utils/ua';
Expand Down Expand Up @@ -65,7 +68,10 @@ export const builder: CommandBuilder<
default: false,
})
.group(['d', 'i', 't', 'r'], 'Command Options:')
.group(['h', 'v'], 'Global Options:');
.group(['h', 'v'], 'Global Options:').epilog(oneLine`
🔑 This command requires an API key. Make sure you've run the "apiKey"
command first.
`);

/**
* Fetches and outputs breach data for the specified account.
Expand Down Expand Up @@ -94,6 +100,7 @@ export const handler = async ({

try {
const breachData = await breachedAccount(account.trim(), {
apiKey: config.get('apiKey'),
domain,
includeUnverified,
truncate,
Expand All @@ -108,10 +115,11 @@ export const handler = async ({
spinner.succeed('Good news — no pwnage found!');
}
} catch (err) {
const errMsg = translateApiError(err.message);
if (!raw) {
spinner.fail(err.message);
spinner.fail(errMsg);
} else {
logger.error(err.message);
logger.error(errMsg);
}
}
};
18 changes: 14 additions & 4 deletions src/commands/pa.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Argv, CommandBuilder } from 'yargs';
import { pasteAccount } from 'hibp';
import prettyjson from 'prettyjson';
import { oneLine } from 'common-tags';
import config from '../utils/config';
import translateApiError from '../utils/translateApiError';
import logger from '../utils/logger';
import spinner from '../utils/spinner';
import userAgent from '../utils/ua';
Expand Down Expand Up @@ -40,7 +43,10 @@ export const builder: CommandBuilder<
default: false,
})
.group(['r'], 'Command Options:')
.group(['h', 'v'], 'Global Options:');
.group(['h', 'v'], 'Global Options:').epilog(oneLine`
🔑 This command requires an API key. Make sure you've run the "apiKey"
command first.
`);

/**
* Fetches and outputs all pastes for an account (email address).
Expand All @@ -59,7 +65,10 @@ export const handler = async ({
}

try {
const pasteData = await pasteAccount(email, { userAgent });
const pasteData = await pasteAccount(email, {
apiKey: config.get('apiKey'),
userAgent,
});
if (pasteData && raw) {
logger.log(JSON.stringify(pasteData));
} else if (pasteData) {
Expand All @@ -69,10 +78,11 @@ export const handler = async ({
spinner.succeed('Good news — no pwnage found!');
}
} catch (err) {
const errMsg = translateApiError(err.message);
if (!raw) {
spinner.fail(err.message);
spinner.fail(errMsg);
} else {
logger.error(err.message);
logger.error(errMsg);
}
}
};
20 changes: 16 additions & 4 deletions src/commands/search.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Argv, CommandBuilder } from 'yargs';
import { search } from 'hibp';
import prettyjson from 'prettyjson';
import { oneLine } from 'common-tags';
import config from '../utils/config';
import translateApiError from '../utils/translateApiError';
import logger from '../utils/logger';
import spinner from '../utils/spinner';
import userAgent from '../utils/ua';
Expand Down Expand Up @@ -57,7 +60,10 @@ export const builder: CommandBuilder<
default: false,
})
.group(['d', 't', 'r'], 'Command Options:')
.group(['h', 'v'], 'Global Options:');
.group(['h', 'v'], 'Global Options:').epilog(oneLine`
🔑 This command requires an API key. Make sure you've run the "apiKey"
command first.
`);

/**
* Fetches and outputs breach and paste data for the specified account.
Expand All @@ -82,7 +88,12 @@ export const handler = async ({
}

try {
const searchData = await search(account, { domain, truncate, userAgent });
const searchData = await search(account, {
apiKey: config.get('apiKey'),
domain,
truncate,
userAgent,
});
const foundData = !!(searchData.breaches || searchData.pastes);
if (foundData && raw) {
logger.log(JSON.stringify(searchData));
Expand All @@ -93,10 +104,11 @@ export const handler = async ({
spinner.succeed('Good news — no pwnage found!');
}
} catch (err) {
const errMsg = translateApiError(err.message);
if (!raw) {
spinner.fail(err.message);
spinner.fail(errMsg);
} else {
logger.error(err.message);
logger.error(errMsg);
}
}
};
9 changes: 9 additions & 0 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Conf from 'conf';

const config = new Conf<string>({
schema: {
apiKey: { type: 'string' },
},
});

export default config;
13 changes: 13 additions & 0 deletions src/utils/translateApiError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import translateApiError from './translateApiError';

describe('util: getErrorMessage', () => {
it('returns a custom error message if input matches "hibp-api-key"', () => {
const orig = 'something something hibp-api-key and stuff';
expect(translateApiError(orig)).not.toBe(orig);
});

it("returns the original message if it doesn't need interpreted", () => {
const orig = 'stuff broke';
expect(translateApiError(orig)).toBe(orig);
});
});
15 changes: 15 additions & 0 deletions src/utils/translateApiError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { oneLine } from 'common-tags';

const translateApiError = (originalMessage: string): string => {
const errMsg = /hibp-api-key/.test(originalMessage)
? oneLine`
Access denied due to invalid or missing API key. Please obtain an API
key from https://haveibeenpwned.com/API/Key and then run "pwned apiKey
<key>" to configure pwned.
`
: originalMessage;

return errMsg;
};

export default translateApiError;
Loading

0 comments on commit 31ff873

Please sign in to comment.