Skip to content

Commit fb01977

Browse files
authored
fix(pwned-check): ZMS-264: Add feature to check password with PwnedPassword API on User Login (#864)
* implement PwnedPasswords check on each login * cache pwnedpasswords API response and reuse the value * refactor, move cache check and api request to separate function * add pwnedpassword check function to tools.js * tools.js and pwned password check in user-handler refactor, remove code repetitions * refactor, improve code readability * checkPwnedPasswordForUser add check for empty password * user-handler improve pwned password cache logic, refactor, fix bug * checkPwnedPasswordForUser use function with await as it is a promise, auth.js add passwordPwned to response and to the schema * slightly optimize checkRes in checkPwnedPassword and make it more readable * remove pwnedpasswords dependency, refactor users.js, checkPwnedPassword fix incorrect cast to number * tools.checkPwnedPassword call in users.js - grab the correct data * default.toml remove rudimentary comment * on request timeout reject with an error object * do not check for pwned password in cache on every login, check every two weeks * when receiving userData also add lastPwnedCheck to projection * remove redis cache, make pwnedpasswords api url configurable * default.toml fix comments * add user id when user has pwned passwords
1 parent 4866524 commit fb01977

File tree

7 files changed

+199
-18
lines changed

7 files changed

+199
-18
lines changed

config/default.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ processes = 1
2424
#secret="a secret cat"
2525
#cipher="aes192" # only for decrypting legacy values (if there are any)
2626

27+
[pwned]
28+
# If enabled then on every login checks the user's passwords against PwnedPasswords API
29+
enabled = false # disabled by default
30+
type = "softfail"
31+
# hardfail -> Fail auth even is API is down or the password is Pwned
32+
# fail -> Fail auth only if the password is Pwned
33+
# softfail -> Auth succeeds even if password is Pwned but a flag is added to API response
34+
# none -> No check is conducted
35+
apiUrl = "" # url of the PwnedPasswords API. If not set then uses the public url, if set then you can use your own API that uses HIBP Passwords downloaded via official PwnedPasswordsDownloader
36+
2737
[webauthn]
2838
rpId = "example.com" # origin domain
2939
rpName = "WildDuck Email Server"

lib/api/auth.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,12 @@ module.exports = (db, server, userHandler) => {
165165
address: Joi.string().required().description('Default email address of authenticated User'),
166166
scope: Joi.string().required().description('The scope this authentication is valid for'),
167167
require2fa: Joi.array().items(Joi.string()).required().description('List of enabled 2FA mechanisms'),
168-
requirePasswordChange: booleanSchema.required().description('Indicates if account hassword has been reset and should be replaced'),
168+
requirePasswordChange: booleanSchema.required().description('Indicates if account password has been reset and should be replaced'),
169169
token: Joi.string().description(
170170
'If access token was requested then this is the value to use as access token when making API requests on behalf of logged in user.'
171+
),
172+
passwordPwned: booleanSchema.description(
173+
'Indicates whether account password has been found in the list of Pwned passwords and should be replaced'
171174
)
172175
}).$_setFlag('objectName', 'AuthenticateResponse')
173176
}
@@ -257,6 +260,10 @@ module.exports = (db, server, userHandler) => {
257260
requirePasswordChange: authData.requirePasswordChange
258261
};
259262

263+
if (authData.passwordPwned) {
264+
authResponse.passwordPwned = authData.passwordPwned;
265+
}
266+
260267
if (result.value.token) {
261268
try {
262269
authResponse.token = await userHandler.generateAuthToken(authData.user);

lib/api/users.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ const BSON = require('bson');
1111
const consts = require('../consts');
1212
const roles = require('../roles');
1313
const imapTools = require('../../imap-core/lib/imap-tools');
14-
const pwnedpasswords = require('pwnedpasswords');
1514
const { nextPageCursorSchema, previousPageCursorSchema, sessSchema, sessIPSchema, booleanSchema, metaDataSchema, usernameSchema } = require('../schemas');
1615
const TaskHandler = require('../task-handler');
1716
const { publish, FORWARD_ADDED } = require('../events');
@@ -472,7 +471,7 @@ module.exports = (db, server, userHandler, settingsHandler) => {
472471

473472
if (result.value.password && !result.value.hashedPassword && !result.value.allowUnsafe) {
474473
try {
475-
let count = await pwnedpasswords(result.value.password);
474+
const { count } = await tools.checkPwnedPassword(result.value.password);
476475
if (count) {
477476
res.status(403);
478477
return res.json({
@@ -1324,7 +1323,7 @@ module.exports = (db, server, userHandler, settingsHandler) => {
13241323

13251324
if (values.password && !values.hashedPassword && !values.allowUnsafe) {
13261325
try {
1327-
let count = await pwnedpasswords(values.password);
1326+
const { count } = await tools.checkPwnedPassword(values.password);
13281327
if (count) {
13291328
res.status(403);
13301329
return res.json({

lib/tools.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const ipaddr = require('ipaddr.js');
1616
const ObjectId = require('mongodb').ObjectId;
1717
const log = require('npmlog');
1818
const addressparser = require('nodemailer/lib/addressparser');
19+
const https = require('https');
1920

2021
let templates = false;
2122

@@ -597,6 +598,85 @@ function buildCertChain(cert, ca) {
597598
.join('\n');
598599
}
599600

601+
function checkPwnedPassword(password, opts = {}) {
602+
const PREFIX_LENGTH = 5;
603+
const API_URL = opts.url || 'https://api.pwnedpasswords.com/range/';
604+
const API_TIMEOUT = 1000;
605+
const HTTP_STATUS_OK = 200;
606+
const HTTP_STATUS_NOT_FOUND = 404;
607+
608+
function hash(password) {
609+
const shasum = crypto.createHash('sha1');
610+
shasum.update(password);
611+
return shasum.digest('hex');
612+
}
613+
614+
function get(hashedPasswordPrefix) {
615+
const opts = {
616+
timeout: API_TIMEOUT
617+
};
618+
619+
return new Promise((resolve, reject) => {
620+
const req = https
621+
.get(API_URL + hashedPasswordPrefix, opts, res => {
622+
let data = '';
623+
624+
// According to API spec, 404 is returned when no hash found, so it is a valid response.
625+
if (res.statusCode !== HTTP_STATUS_OK && res.statusCode !== HTTP_STATUS_NOT_FOUND) {
626+
return reject(new Error(`Failed to load pwnedpasswords API: ${res.statusCode}`));
627+
}
628+
629+
res.on('data', chunk => {
630+
data += chunk;
631+
});
632+
633+
res.on('end', () => {
634+
resolve(data);
635+
});
636+
637+
return true;
638+
})
639+
.on('error', err => {
640+
reject(err);
641+
})
642+
.on('timeout', () => {
643+
req.destroy();
644+
reject(new Error('pwnedpassword API timeout'));
645+
});
646+
});
647+
}
648+
649+
if (typeof password !== 'string') {
650+
const err = new Error('Input password must be a string.');
651+
return Promise.reject(err);
652+
}
653+
654+
const hashedPassword = hash(password);
655+
const hashedPasswordPrefix = hashedPassword.substring(0, PREFIX_LENGTH);
656+
const hashedPasswordSuffix = hashedPassword.substring(PREFIX_LENGTH);
657+
658+
function checkRes(res) {
659+
const count = Number(
660+
res
661+
.split('\n')
662+
.map(line => line.split(':'))
663+
.find(([suffix]) => suffix.toLowerCase() === hashedPasswordSuffix)?.[1] ?? 0
664+
);
665+
666+
return { count, lines: res };
667+
}
668+
669+
if (opts.cache) {
670+
return checkRes(opts.cache);
671+
}
672+
673+
return get(hashedPasswordPrefix)
674+
.then(res => checkRes(res))
675+
.catch(err => {
676+
throw err;
677+
});
678+
}
679+
600680
function parseFilterQueryText(queryText) {
601681
if (!queryText || typeof queryText !== 'string') {
602682
return { andTerms: [], orTerms: [] };
@@ -697,6 +777,7 @@ module.exports = {
697777
roundTime,
698778
parsePemBundle,
699779
buildCertChain,
780+
checkPwnedPassword,
700781
parseFilterQueryText,
701782
filterQueryTermMatches,
702783
extractQuotedPhrases,

lib/user-handler.js

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,8 @@ class UserHandler {
504504
webauthn: true,
505505
disabled: true,
506506
suspended: true,
507-
disabledScopes: true
507+
disabledScopes: true,
508+
lastPwnedCheck: true
508509
},
509510
maxTimeMS: consts.DB_MAX_TIME_USERS
510511
});
@@ -581,6 +582,75 @@ class UserHandler {
581582
return [false, false];
582583
}
583584

585+
let isPasswordPwned = false;
586+
const twoWeeksMS = 14 * 24 * 60 * 60 * 1000;
587+
if (
588+
config.pwned &&
589+
config.pwned.enabled &&
590+
config.pwned.type &&
591+
config.pwned.type !== 'none' &&
592+
(!userData.lastPwnedCheck || new Date() - userData.lastPwnedCheck >= twoWeeksMS)
593+
) {
594+
try {
595+
const opts = {};
596+
if (config.pwned.apiUrl) {
597+
opts.url = config.pwned.apiUrl;
598+
}
599+
isPasswordPwned = await this.checkPwnedPasswordForUser(password, opts);
600+
601+
await this.users.collection('users').updateOne(userQuery, { $set: { lastPwnedCheck: new Date() } });
602+
// error can be ignored, in which case the value is not set and another pwned check will be conducted but it will be cashed
603+
604+
if (isPasswordPwned) {
605+
if (config.pwned.type === 'softfail') {
606+
this.loggelf({
607+
short_message: '[AUTHSOFTFAIL] ' + username,
608+
_error: 'Pwned password found',
609+
_auth_result: 'softfail',
610+
_user: userData._id,
611+
_username: username,
612+
_domain: userDomain,
613+
_scope: requiredScope,
614+
_ip: meta.ip,
615+
_sess: meta.sess
616+
});
617+
} else if (config.pwned.type === 'fail') {
618+
// Hard fail the Pwned password check
619+
this.loggelf({
620+
short_message: '[AUTHFAIL] ' + username,
621+
_error: 'Pwned password found',
622+
_auth_result: 'fail',
623+
_user: userData._id,
624+
_username: username,
625+
_domain: userDomain,
626+
_scope: requiredScope,
627+
_ip: meta.ip,
628+
_sess: meta.sess
629+
});
630+
631+
return [false, false];
632+
}
633+
}
634+
} catch {
635+
if (config.pwned.type === 'hardfail') {
636+
// do not ignore errors, hard fail auth
637+
this.loggelf({
638+
short_message: '[AUTHFAIL] ' + username,
639+
_error: 'Pwned password - API Error',
640+
_auth_result: 'fail',
641+
_user: userData._id,
642+
_username: username,
643+
_domain: userDomain,
644+
_scope: requiredScope,
645+
_ip: meta.ip,
646+
_sess: meta.sess
647+
});
648+
return [false, false];
649+
}
650+
// ignore errors, soft check only
651+
}
652+
}
653+
584654
// make sure we use the primary domain if available
585655
userDomain = (userData.address || '').split('@').pop() || userDomain;
586656

@@ -849,7 +919,7 @@ class UserHandler {
849919
// ignore
850920
}
851921

852-
let authResponse = {
922+
const authResponse = {
853923
user: userData._id,
854924
username: userData.username,
855925
scope: meta.requiredScope,
@@ -863,6 +933,10 @@ class UserHandler {
863933
authResponse.enabled2fa = enabled2fa;
864934
}
865935

936+
if (isPasswordPwned) {
937+
authResponse.passwordPwned = isPasswordPwned;
938+
}
939+
866940
return await authSuccess(authResponse);
867941
}
868942

@@ -1002,13 +1076,19 @@ class UserHandler {
10021076
// ignore
10031077
}
10041078

1005-
return await authSuccess({
1079+
const authResponse = {
10061080
user: userData._id,
10071081
username: userData.username,
10081082
scope: requiredScope,
10091083
asp: asp._id.toString(),
10101084
require2fa: false // application scope never requires 2FA
1011-
});
1085+
};
1086+
1087+
if (isPasswordPwned) {
1088+
authResponse.passwordPwned = isPasswordPwned;
1089+
}
1090+
1091+
return await authSuccess(authResponse);
10121092
}
10131093

10141094
// no suitable password found
@@ -3871,6 +3951,20 @@ class UserHandler {
38713951
let accessToken = crypto.randomBytes(20).toString('hex');
38723952
return await this.setAuthToken(user, accessToken);
38733953
}
3954+
3955+
async checkPwnedPasswordForUser(password, opts = {}) {
3956+
if (!password) {
3957+
return false;
3958+
}
3959+
3960+
const { count } = await tools.checkPwnedPassword(password, opts);
3961+
3962+
if (count) {
3963+
return true; // password pwned
3964+
}
3965+
3966+
return false; // password not pwned and no errors
3967+
}
38743968
}
38753969

38763970
function rateLimitResponse(res) {

package-lock.json

Lines changed: 0 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@
9090
"openpgp": "5.11.2",
9191
"pem-jwk": "2.0.0",
9292
"punycode.js": "2.3.1",
93-
"pwnedpasswords": "1.0.6",
9493
"qrcode": "1.5.4",
9594
"restify": "11.1.0",
9695
"restify-cors-middleware2": "2.2.1",

0 commit comments

Comments
 (0)