Skip to content

Commit 31ff873

Browse files
committed
feat(api-v3): add apiKey command and pass API key to protected services
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.
1 parent ae2f30e commit 31ff873

9 files changed

+228
-12
lines changed

package.json

+3
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@
6262
"node": ">= 8"
6363
},
6464
"dependencies": {
65+
"common-tags": "^1.8.0",
66+
"conf": "^5.0.0",
6567
"hibp": "^8.0.0",
6668
"ora": "^3.4.0",
6769
"prettyjson": "^1.2.1",
@@ -76,6 +78,7 @@
7678
"@babel/preset-typescript": "7.3.3",
7779
"@commitlint/cli": "8.1.0",
7880
"@commitlint/config-conventional": "8.1.0",
81+
"@types/common-tags": "1.8.0",
7982
"@types/jest": "24.0.15",
8083
"@types/node": "12.6.8",
8184
"@types/ora": "3.2.0",

src/commands/apiKey.ts

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Argv, CommandBuilder } from 'yargs';
2+
import { oneLine } from 'common-tags';
3+
import config from '../utils/config';
4+
import logger from '../utils/logger';
5+
6+
export const command = 'apiKey <key>';
7+
export const describe = 'set the API key to be used for authenticated requests';
8+
9+
interface ApiKeyArgvOptions {
10+
key: string;
11+
}
12+
13+
interface ApiKeyHandlerOptions {
14+
key: string;
15+
}
16+
17+
export const builder: CommandBuilder<
18+
ApiKeyArgvOptions,
19+
ApiKeyHandlerOptions
20+
> /* istanbul ignore next */ = (yargs): Argv<ApiKeyHandlerOptions> =>
21+
yargs
22+
.positional('key', { type: 'string' })
23+
.demandOption('key')
24+
.check(argv => {
25+
if (!argv.key.length) {
26+
throw new Error('The key argument must not be empty.');
27+
}
28+
return true;
29+
})
30+
.group(['h', 'v'], 'Global Options:').epilog(oneLine`
31+
Please obtain an API key from https://haveibeenpwned.com/API/Key and then
32+
run "pwned apiKey <key>" to configure pwned.
33+
`);
34+
35+
/**
36+
* Stores the user's specified API key to be used for future requests to
37+
* authenticated endpoints.
38+
*
39+
* @param {object} argv the parsed argv object
40+
* @param {string} argv.key the user's API key
41+
* @returns {Promise<void>} the resulting Promise where output is rendered
42+
*/
43+
export const handler = async ({ key }: ApiKeyHandlerOptions): Promise<void> => {
44+
try {
45+
config.set('apiKey', key);
46+
if (config.get('apiKey') === key) {
47+
logger.log(oneLine`
48+
✔ API key saved successfully. It will be used in future requests made
49+
to haveibeenpwned.com services that require authentication.
50+
`);
51+
} else {
52+
throw new Error(oneLine`
53+
✖ API key mismatch: the key read back from config does not match the
54+
key supplied!
55+
`);
56+
}
57+
} catch (err) {
58+
logger.error(err.message);
59+
}
60+
};

src/commands/ba.ts

+11-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { Argv, CommandBuilder } from 'yargs';
22
import { breachedAccount } from 'hibp';
33
import prettyjson from 'prettyjson';
4+
import { oneLine } from 'common-tags';
5+
import config from '../utils/config';
6+
import translateApiError from '../utils/translateApiError';
47
import logger from '../utils/logger';
58
import spinner from '../utils/spinner';
69
import userAgent from '../utils/ua';
@@ -65,7 +68,10 @@ export const builder: CommandBuilder<
6568
default: false,
6669
})
6770
.group(['d', 'i', 't', 'r'], 'Command Options:')
68-
.group(['h', 'v'], 'Global Options:');
71+
.group(['h', 'v'], 'Global Options:').epilog(oneLine`
72+
🔑 This command requires an API key. Make sure you've run the "apiKey"
73+
command first.
74+
`);
6975

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

95101
try {
96102
const breachData = await breachedAccount(account.trim(), {
103+
apiKey: config.get('apiKey'),
97104
domain,
98105
includeUnverified,
99106
truncate,
@@ -108,10 +115,11 @@ export const handler = async ({
108115
spinner.succeed('Good news — no pwnage found!');
109116
}
110117
} catch (err) {
118+
const errMsg = translateApiError(err.message);
111119
if (!raw) {
112-
spinner.fail(err.message);
120+
spinner.fail(errMsg);
113121
} else {
114-
logger.error(err.message);
122+
logger.error(errMsg);
115123
}
116124
}
117125
};

src/commands/pa.ts

+14-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { Argv, CommandBuilder } from 'yargs';
22
import { pasteAccount } from 'hibp';
33
import prettyjson from 'prettyjson';
4+
import { oneLine } from 'common-tags';
5+
import config from '../utils/config';
6+
import translateApiError from '../utils/translateApiError';
47
import logger from '../utils/logger';
58
import spinner from '../utils/spinner';
69
import userAgent from '../utils/ua';
@@ -40,7 +43,10 @@ export const builder: CommandBuilder<
4043
default: false,
4144
})
4245
.group(['r'], 'Command Options:')
43-
.group(['h', 'v'], 'Global Options:');
46+
.group(['h', 'v'], 'Global Options:').epilog(oneLine`
47+
🔑 This command requires an API key. Make sure you've run the "apiKey"
48+
command first.
49+
`);
4450

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

6167
try {
62-
const pasteData = await pasteAccount(email, { userAgent });
68+
const pasteData = await pasteAccount(email, {
69+
apiKey: config.get('apiKey'),
70+
userAgent,
71+
});
6372
if (pasteData && raw) {
6473
logger.log(JSON.stringify(pasteData));
6574
} else if (pasteData) {
@@ -69,10 +78,11 @@ export const handler = async ({
6978
spinner.succeed('Good news — no pwnage found!');
7079
}
7180
} catch (err) {
81+
const errMsg = translateApiError(err.message);
7282
if (!raw) {
73-
spinner.fail(err.message);
83+
spinner.fail(errMsg);
7484
} else {
75-
logger.error(err.message);
85+
logger.error(errMsg);
7686
}
7787
}
7888
};

src/commands/search.ts

+16-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { Argv, CommandBuilder } from 'yargs';
22
import { search } from 'hibp';
33
import prettyjson from 'prettyjson';
4+
import { oneLine } from 'common-tags';
5+
import config from '../utils/config';
6+
import translateApiError from '../utils/translateApiError';
47
import logger from '../utils/logger';
58
import spinner from '../utils/spinner';
69
import userAgent from '../utils/ua';
@@ -57,7 +60,10 @@ export const builder: CommandBuilder<
5760
default: false,
5861
})
5962
.group(['d', 't', 'r'], 'Command Options:')
60-
.group(['h', 'v'], 'Global Options:');
63+
.group(['h', 'v'], 'Global Options:').epilog(oneLine`
64+
🔑 This command requires an API key. Make sure you've run the "apiKey"
65+
command first.
66+
`);
6167

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

8490
try {
85-
const searchData = await search(account, { domain, truncate, userAgent });
91+
const searchData = await search(account, {
92+
apiKey: config.get('apiKey'),
93+
domain,
94+
truncate,
95+
userAgent,
96+
});
8697
const foundData = !!(searchData.breaches || searchData.pastes);
8798
if (foundData && raw) {
8899
logger.log(JSON.stringify(searchData));
@@ -93,10 +104,11 @@ export const handler = async ({
93104
spinner.succeed('Good news — no pwnage found!');
94105
}
95106
} catch (err) {
107+
const errMsg = translateApiError(err.message);
96108
if (!raw) {
97-
spinner.fail(err.message);
109+
spinner.fail(errMsg);
98110
} else {
99-
logger.error(err.message);
111+
logger.error(errMsg);
100112
}
101113
}
102114
};

src/utils/config.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import Conf from 'conf';
2+
3+
const config = new Conf<string>({
4+
schema: {
5+
apiKey: { type: 'string' },
6+
},
7+
});
8+
9+
export default config;

src/utils/translateApiError.test.ts

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import translateApiError from './translateApiError';
2+
3+
describe('util: getErrorMessage', () => {
4+
it('returns a custom error message if input matches "hibp-api-key"', () => {
5+
const orig = 'something something hibp-api-key and stuff';
6+
expect(translateApiError(orig)).not.toBe(orig);
7+
});
8+
9+
it("returns the original message if it doesn't need interpreted", () => {
10+
const orig = 'stuff broke';
11+
expect(translateApiError(orig)).toBe(orig);
12+
});
13+
});

src/utils/translateApiError.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { oneLine } from 'common-tags';
2+
3+
const translateApiError = (originalMessage: string): string => {
4+
const errMsg = /hibp-api-key/.test(originalMessage)
5+
? oneLine`
6+
Access denied due to invalid or missing API key. Please obtain an API
7+
key from https://haveibeenpwned.com/API/Key and then run "pwned apiKey
8+
<key>" to configure pwned.
9+
`
10+
: originalMessage;
11+
12+
return errMsg;
13+
};
14+
15+
export default translateApiError;

0 commit comments

Comments
 (0)