Support a single u2f key

This commit is contained in:
Andris Reinman 2017-10-10 11:19:10 +03:00
parent d3e59eae2b
commit ece4080ce8
8 changed files with 1101 additions and 113 deletions

6
api.js
View file

@ -16,7 +16,8 @@ const mailboxesRoutes = require('./lib/api/mailboxes');
const messagesRoutes = require('./lib/api/messages');
const filtersRoutes = require('./lib/api/filters');
const aspsRoutes = require('./lib/api/asps');
const _2faRoutes = require('./lib/api/2fa');
const totpRoutes = require('./lib/api/2fa/totp');
const u2fRoutes = require('./lib/api/2fa/u2f');
const updatesRoutes = require('./lib/api/updates');
const authRoutes = require('./lib/api/auth');
const autoreplyRoutes = require('./lib/api/autoreply');
@ -133,7 +134,8 @@ module.exports = done => {
messagesRoutes(db, server, messageHandler);
filtersRoutes(db, server);
aspsRoutes(db, server, userHandler);
_2faRoutes(db, server, userHandler);
totpRoutes(db, server, userHandler);
u2fRoutes(db, server, userHandler);
updatesRoutes(db, server, notifier);
authRoutes(db, server, userHandler);
autoreplyRoutes(db, server);

View file

@ -36,6 +36,10 @@ bugsnagCode=""
#cipher="aes192"
#secret="a secret cat"
[u2f]
# Fully qualified URL of your website (must use HTTPS!)
appId="https://localhost:3000"
[attachments]
# @include "attachments.toml"

View file

@ -410,7 +410,7 @@ Authenticates an user
- **id** is the id of the authenticated user
- **username** is the user name of the logged in user (useful if you logged in used)
- **scope** is the scope this authentication is valid for
- **require2fa** if `true` then the user should also [provide a 2FA token](#verify-2fa) before the user is allowed to proceed
- **require2fa** is an array of enabled 2FA mechanisms for this user
- **requirePasswordChange** if `true` then the user should be forced to change their password
**Example**
@ -481,20 +481,26 @@ Log entries expire after 30 days.
## 2FA
Wild Duck supports TOTP based 2FA. If 2FA is enabled then users are requested to enter authentication token after successful login. Also, with 2FA enabled, master password can not be used in IMAP, POP3 or SMTP. The user must create an [Application Specific Password](#application-specific-passwords) with a correct scope for email clients using these protocols.
Wild Duck supports TOTP and U2f based 2FA. If 2FA is enabled then users are requested to enter authentication token after successful login. Also, with 2FA enabled, master password can not be used in IMAP, POP3 or SMTP. The user must create an [Application Specific Password](#application-specific-passwords) with a correct scope for email clients using these protocols.
2FA checks do not happen magically, your application must be 2FA aware:
1. Authenticate user with the [/authenticate](#authenticate-an-user) call
2. If authentication result includes `requirePasswordChange:true` then force user to change their password
3. If authentication result includes `require2fa:false` then do nothing, the user is now authenticated. Otherwise continue with Step 4.
4. Request TOTP token from the user before allowing to perform other actions
5. Check the token with [/user/{user}/2fa?token=123456](#check-2fa)
6. If token verification succeeds then user is authenticated
3. If authentication result includes `require2fa:false` then do nothing, the user is now authenticated. Otherwise continue with Step 4. or Step 5.
4. If `require2fa` array includes 'totp' then:
1. Request TOTP token from the user before allowing to perform other actions
2. Check the token with */user/{user}/2fa/totp/check*
3. If token verification succeeds then user is authenticated
5. If `require2fa` array includes 'u2f' then:
1. Authentication response should include u2fAuthRequest object. If it is missing or verification times out then you can fetch a new U2F request object from the server with */user/{user}/2fa/u2f/start*
2. Send authentication request to U2F key
3. Send authentication response from key to server with */user/{user}/2fa/totp/check*
4. If token verification succeeds then user is authenticated
### Setup 2FA
#### POST /users/{user}/2fa
#### POST /users/{user}/2fa/totp/setup
This call prepares the user to support 2FA tokens. If 2FA is already enabled then this call fails.
@ -513,7 +519,7 @@ This call prepares the user to support 2FA tokens. If 2FA is already enabled the
**Example**
```
curl -XPOST "http://localhost:8080/users/5971da1754cfdc7f0983b2ec/2fa" -H 'content-type: application/json' -d '{
curl -XPOST "http://localhost:8080/users/5971da1754cfdc7f0983b2ec/2fa/totp/setup" -H 'content-type: application/json' -d '{
"issuer": "testikas",
"ip": "192.168.10.10"
}'
@ -530,7 +536,7 @@ Response for a successful operation:
### Verify 2FA
#### PUT /users/{user}/2fa
#### POST /users/{user}/2fa/totp/enable
Once 2FA QR code is generated the user must return the token with this call. Once the token is successfully provided then 2FA is enabled for the account.
@ -547,7 +553,7 @@ Once 2FA QR code is generated the user must return the token with this call. Onc
**Example**
```
curl -XPUT "http://localhost:8080/users/5971da1754cfdc7f0983b2ec/2fa" -H 'content-type: application/json' -d '{
curl -XPOST "http://localhost:8080/users/5971da1754cfdc7f0983b2ec/2fa/totp/enable" -H 'content-type: application/json' -d '{
"token": "455912",
"ip": "192.168.10.10"
}'
@ -561,40 +567,11 @@ Response for a successful operation:
}
```
### Disable 2FA
#### DELETE /users/{user}/2fa
Disabling 2FA re-enables master password usage for IMAP, POP3 and SMTP.
**Parameters**
- **user** (required) is the ID of the user
- **ip** is the IP address the request was made from
**Response fields**
- **success** should be `true`
**Example**
```
curl -XDELETE "http://localhost:8080/users/5971da1754cfdc7f0983b2ec/2fa?ip=192.168.10.10"
```
Response for a successful operation:
```json
{
"success": true
}
```
### Check 2FA
#### GET /users/{user}/2fa
#### POST /users/{user}/2fa/totp/check
Validates a TOTP token against user 2FA settings. This check should be performed when an user authentication response includes `request2fa:true`
Validates a TOTP token against user 2FA settings. This check should be performed when an user authentication response includes `request2fa:['totp']`
**Parameters**
@ -609,7 +586,68 @@ Validates a TOTP token against user 2FA settings. This check should be performed
**Example**
```
curl "http://localhost:8080/users/5971da1754cfdc7f0983b2ec/2fa?token=123456&ip=192.168.10.10"
curl -XPOST "http://localhost:8080/users/5971da1754cfdc7f0983b2ec/2fa/totp/check" -H 'content-type: application/json' -d '{
"token": "455912",
"ip": "192.168.10.10"
}'
```
Response for a successful operation:
```json
{
"success": true
}
```
### Disable TOTP
#### DELETE /users/{user}/2fa/totp
Disabling TOTP for authentication. Other 2FA schemes remain in place.
**Parameters**
- **user** (required) is the ID of the user
- **ip** is the IP address the request was made from
**Response fields**
- **success** should be `true`
**Example**
```
curl -XDELETE "http://localhost:8080/users/5971da1754cfdc7f0983b2ec/2fa/totp?ip=192.168.10.10"
```
Response for a successful operation:
```json
{
"success": true
}
```
### Disable 2FA
#### DELETE /users/{user}/2fa
Disables all 2FA schemes. Disabling 2FA re-enables master password usage for IMAP, POP3 and SMTP.
**Parameters**
- **user** (required) is the ID of the user
- **ip** is the IP address the request was made from
**Response fields**
- **success** should be `true`
**Example**
```
curl -XDELETE "http://localhost:8080/users/5971da1754cfdc7f0983b2ec/2fa?ip=192.168.10.10"
```
Response for a successful operation:

View file

@ -4,13 +4,22 @@ const Joi = require('joi');
const ObjectID = require('mongodb').ObjectID;
module.exports = (db, server, userHandler) => {
server.post('/users/:user/2fa', (req, res, next) => {
// Create TOTP seed and request a QR code
server.post('/users/:user/2fa/totp/setup', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required(),
issuer: Joi.string().trim().max(255).required(),
fresh: Joi.boolean().truthy(['Y', 'true', 'yes', 1]).default(false),
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
issuer: Joi.string()
.trim()
.max(255)
.required(),
fresh: Joi.boolean()
.truthy(['Y', 'true', 'yes', 1])
.default(false),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
@ -31,7 +40,7 @@ module.exports = (db, server, userHandler) => {
let user = new ObjectID(result.value.user);
userHandler.setup2fa(user, result.value, (err, result) => {
userHandler.setupTotp(user, result.value, (err, result) => {
if (err) {
res.json({
error: err.message
@ -48,12 +57,72 @@ module.exports = (db, server, userHandler) => {
});
});
server.get('/users/:user/2fa', (req, res, next) => {
// Send token from QR code to enable TOTP auth for a client
server.post('/users/:user/2fa/totp/enable', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required(),
token: Joi.string().length(6).required(),
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
token: Joi.string()
.length(6)
.required(),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
})
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message
});
return next();
}
let user = new ObjectID(result.value.user);
userHandler.enableTotp(user, result.value, (err, result) => {
if (err) {
res.json({
error: err.message
});
return next();
}
if (!result) {
res.json({
error: 'Invalid authentication token'
});
return next();
}
res.json({
success: true
});
return next();
});
});
// Disable TOTP auth for an user
server.del('/users/:user/2fa/totp', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
@ -76,7 +145,7 @@ module.exports = (db, server, userHandler) => {
let user = new ObjectID(result.value.user);
userHandler.check2fa(user, result.value, (err, result) => {
userHandler.disableTotp(user, result.value, (err, result) => {
if (err) {
res.json({
error: err.message
@ -99,12 +168,19 @@ module.exports = (db, server, userHandler) => {
});
});
server.put('/users/:user/2fa', (req, res, next) => {
// Send current TOTP code to authenticate an user
server.post('/users/:user/2fa/totp/check', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required(),
token: Joi.string().length(6).required(),
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
token: Joi.string()
.length(6)
.required(),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
@ -125,7 +201,7 @@ module.exports = (db, server, userHandler) => {
let user = new ObjectID(result.value.user);
userHandler.enable2fa(user, result.value, (err, result) => {
userHandler.checkTotp(user, result.value, (err, result) => {
if (err) {
res.json({
error: err.message
@ -135,7 +211,7 @@ module.exports = (db, server, userHandler) => {
if (!result) {
res.json({
error: 'Invalid authentication token'
error: 'Failed to validate TOTP'
});
return next();
}
@ -148,11 +224,16 @@ module.exports = (db, server, userHandler) => {
});
});
// Disable 2FA auth for an user
server.del('/users/:user/2fa', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required(),
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
@ -185,7 +266,7 @@ module.exports = (db, server, userHandler) => {
if (!result) {
res.json({
error: 'Invalid authentication token'
error: 'Failed to disable U2F'
});
return next();
}

318
lib/api/2fa/u2f.js Normal file
View file

@ -0,0 +1,318 @@
'use strict';
const Joi = require('joi');
const ObjectID = require('mongodb').ObjectID;
const U2F_ERRORS = new Map([
[1, 'Unknown error'],
[2, 'Bad request'],
[3, 'Client configuration is not supported'],
[4, 'The presented device is not eligible for this request'],
[5, 'Timeout reached while waiting for key']
]);
module.exports = (db, server, userHandler) => {
// Create U2F keys
server.post('/users/:user/2fa/u2f/setup', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
})
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message
});
return next();
}
let user = new ObjectID(result.value.user);
userHandler.setupU2f(user, result.value, (err, u2fRegRequest) => {
if (err) {
res.json({
error: err.message
});
return next();
}
res.json({
success: true,
u2fRegRequest
});
return next();
});
});
/*
var t = {
registrationData:
'BQSp4XE8GaJNIHEpWRa6sVkKeIcCqr2ODhi9FL9b4ac70ttiKH9I4rK6Y7eV9HVFQX78T_YyYhXL89__bZxmjX4TQJQZHupSA74vy9WPHjnBA69G1tfLfjQ4nFxiscGneMh2PTBzPjUyKBlHJkg_WJtVCThL2Lbc5WQ8ziU37c52uLEwggJEMIIBLqADAgECAgRVYr6gMAsGCSqGSIb3DQEBCzAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowKjEoMCYGA1UEAwwfWXViaWNvIFUyRiBFRSBTZXJpYWwgMTQzMjUzNDY4ODBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABEszH3c9gUS5mVy-RYVRfhdYOqR2I2lcvoWsSCyAGfLJuUZ64EWw5m8TGy6jJDyR_aYC4xjz_F2NKnq65yvRQwmjOzA5MCIGCSsGAQQBgsQKAgQVMS4zLjYuMS40LjEuNDE0ODIuMS41MBMGCysGAQQBguUcAgEBBAQDAgUgMAsGCSqGSIb3DQEBCwOCAQEArBbZs262s6m3bXWUs09Z9Pc-28n96yk162tFHKv0HSXT5xYU10cmBMpypXjjI-23YARoXwXn0bm-BdtulED6xc_JMqbK-uhSmXcu2wJ4ICA81BQdPutvaizpnjlXgDJjq6uNbsSAp98IStLLp7fW13yUw-vAsWb5YFfK9f46Yx6iakM3YqNvvs9M9EUJYl_VrxBJqnyLx2iaZlnpr13o8NcsKIJRdMUOBqt_ageQg3ttsyq_3LyoNcu7CQ7x8NmeCGm_6eVnZMQjDmwFdymwEN4OxfnM5MkcKCYhjqgIGruWkVHsFnJa8qjZXneVvKoiepuUQyDEJ2GcqvhU2YKY1zBEAiBKahEVX1Kw2X6rL1kKeskPU-fNqwqLo5S1ylHDcesRpgIgPNg0uHVswZquH6YLfUSNUKg_bYBGXOxHKWH5qNl2bB4',
version: 'U2F_V2',
challenge: '2kbypDmNIkM6-oaVKjB7ZN1J1jiyzoU8WxLGX8yVUpY',
clientData:
'eyJ0eXAiOiJuYXZpZ2F0b3IuaWQuZmluaXNoRW5yb2xsbWVudCIsImNoYWxsZW5nZSI6IjJrYnlwRG1OSWtNNi1vYVZLakI3Wk4xSjFqaXl6b1U4V3hMR1g4eVZVcFkiLCJvcmlnaW4iOiJodHRwczovL2xvY2FsaG9zdDozMDAwIiwiY2lkX3B1YmtleSI6InVudXNlZCJ9'
};
*/
// Send response from U2F key
server.post('/users/:user/2fa/u2f/enable', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
errorCode: Joi.number().max(100),
clientData: Joi.string()
.regex(/^[0-9a-z\-_]+$/i, 'web safe base64')
.max(10240),
registrationData: Joi.string()
.regex(/^[0-9a-z\-_]+$/i, 'web safe base64')
.max(10240),
version: Joi.string().allow('U2F_V2'),
challenge: Joi.string()
.regex(/^[0-9a-z\-_]+$/i, 'web safe base64')
.max(1024),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
})
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message
});
return next();
}
if (result.value.errorCode) {
res.json({
error: U2F_ERRORS.get(result.value.errorCode) || 'Unknown error'
});
return next();
}
let user = new ObjectID(result.value.user);
userHandler.enableU2f(user, result.value, (err, result) => {
if (err) {
res.json({
error: err.message
});
return next();
}
if (!result) {
res.json({
error: 'Failed to enable U2F'
});
return next();
}
res.json({
success: true
});
return next();
});
});
// Disable U2F auth for an user
server.del('/users/:user/2fa/u2f', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
})
});
req.query.user = req.params.user;
const result = Joi.validate(req.query, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message
});
return next();
}
let user = new ObjectID(result.value.user);
userHandler.disableU2f(user, result.value, (err, result) => {
if (err) {
res.json({
error: err.message
});
return next();
}
if (!result) {
res.json({
error: 'Failed to disable U2F'
});
return next();
}
res.json({
success: true
});
return next();
});
});
// Generate U2F Authentciation Request
server.post('/users/:user/2fa/u2f/start', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
})
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message
});
return next();
}
let user = new ObjectID(result.value.user);
userHandler.startU2f(user, result.value, (err, u2fAuthRequest) => {
if (err) {
res.json({
error: err.message
});
return next();
}
if (!result) {
res.json({
error: 'Failed to generate authentication request for U2F'
});
return next();
}
res.json({
success: true,
u2fAuthRequest
});
return next();
});
});
// Send response from U2F key
server.post('/users/:user/2fa/u2f/check', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
errorCode: Joi.number().max(100),
clientData: Joi.string()
.regex(/^[0-9a-z\-_]+$/i, 'web safe base64')
.max(10240),
signatureData: Joi.string()
.regex(/^[0-9a-z\-_]+$/i, 'web safe base64')
.max(10240),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
})
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message
});
return next();
}
if (result.value.errorCode) {
res.json({
error: U2F_ERRORS.get(result.value.errorCode) || 'Unknown error'
});
return next();
}
let user = new ObjectID(result.value.user);
userHandler.checkU2f(user, result.value, (err, result) => {
if (err) {
res.json({
error: err.message
});
return next();
}
if (!result) {
res.json({
error: 'Failed to validate U2F request'
});
return next();
}
res.json({
success: true
});
return next();
});
});
};

View file

@ -64,14 +64,20 @@ module.exports = (db, server, userHandler) => {
return next();
}
res.json({
let authResponse = {
success: true,
id: authData.user,
username: authData.username,
scope: authData.scope,
require2fa: authData.require2fa,
requirePasswordChange: authData.requirePasswordChange
});
};
if (authData.u2fAuthRequest) {
authResponse.u2fAuthRequest = authData.u2fAuthRequest;
}
res.json(authResponse);
return next();
});

View file

@ -16,6 +16,7 @@ const mailboxTranslations = require('./translations');
const base32 = require('base32.js');
const MailComposer = require('nodemailer/lib/mail-composer');
const humanname = require('humanname');
const u2f = require('u2f');
class UserHandler {
constructor(options) {
@ -164,6 +165,8 @@ class UserHandler {
username: true,
password: true,
enabled2fa: true,
u2fKeyHandle: true,
u2fPubKey: true,
requirePasswordChange: true,
disabled: true
}
@ -190,6 +193,8 @@ class UserHandler {
return this.logAuthEvent(userData._id, meta, () => callback(null, false));
}
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
let rlkey = 'auth:' + userData._id.toString();
this.counters.ttlcounter(rlkey, 0, consts.AUTH_FAILURES, consts.AUTH_WINDOW, (err, res) => {
if (err) {
@ -201,6 +206,13 @@ class UserHandler {
return callback(err);
}
let getU2fAuthRequest = done => {
if (!enabled2fa.includes('u2f') || !userData.u2fKeyHandle) {
return done(null, false);
}
this.generateU2fAuthRequest(userData._id, userData.u2fKeyHandle, done);
};
let authSuccess = (...args) => {
// clear rate limit counter on success
this.redis.del(rlkey, () => false);
@ -222,19 +234,32 @@ class UserHandler {
if (success) {
meta.result = 'success';
meta.source = 'master';
if (userData.enabled2fa) {
meta.require2fa = true;
if (enabled2fa.length) {
meta.require2fa = enabled2fa.length ? enabled2fa.join(',') : false;
}
meta.groupKey = ['authenticate', meta.protocol, meta.result, meta.source, meta.ip].join(':');
return this.logAuthEvent(userData._id, meta, () =>
authSuccess(null, {
return this.logAuthEvent(userData._id, meta, () => {
let authResponse = {
user: userData._id,
username: userData.username,
scope: 'master',
// if 2FA is enabled then require token validation
require2fa: !!userData.enabled2fa
})
);
require2fa: enabled2fa.length ? enabled2fa : false,
requirePasswordChange: !!userData.requirePasswordChange // true, if password was reset
};
if (enabled2fa.length) {
authResponse.enabled2fa = enabled2fa;
}
getU2fAuthRequest((err, u2fAuthRequest) => {
if (err) {
log.error('DB', 'U2FREFAIL u2fAuthRequest id=%s error=%s', userData._id, err.message);
}
if (u2fAuthRequest) {
authResponse.u2fAuthRequest = u2fAuthRequest;
}
authSuccess(null, authResponse);
});
});
}
if (requiredScope === 'master') {
@ -322,8 +347,7 @@ class UserHandler {
username: userData.username,
scope: requiredScope,
asp: asp._id.toString(),
require2fa: false, // application scope never requires 2FA
requirePasswordChange: !!userData.requirePasswordChange // true, if password was reset
require2fa: false // application scope never requires 2FA
});
});
});
@ -337,6 +361,36 @@ class UserHandler {
});
}
generateU2fAuthRequest(user, keyHandle, callback) {
let authRequest;
try {
authRequest = u2f.request(config.u2f.appId, keyHandle);
} catch (E) {
log.error('U2F', 'U2FFAIL request id=%s error=%s', user, E.message);
}
if (!authRequest) {
return callback(null, false);
}
this.redis
.multi()
.set('u2f:auth:' + user, JSON.stringify(authRequest))
.expire('u2f:auth:' + user, 1 * 3600)
.exec((err, results) => {
if ((!err && !results) || !results[0]) {
err = new Error('Invalid DB response');
} else if (!err && results && results[0] && results[0][0]) {
err = results[0][0];
}
if (err) {
return callback(err);
}
callback(null, authRequest);
});
}
generateASP(user, data, callback) {
let password = generatePassword.generate({
length: 16,
@ -734,8 +788,11 @@ class UserHandler {
_id: user
}, {
$set: {
enabled2fa: false,
enabled2fa: [],
seed: '',
u2FKeyHandle: '',
u2fPubKey: '',
u2fCert: '',
requirePasswordChange: true,
password: bcrypt.hashSync(password, consts.BCRYPT_ROUNDS)
}
@ -754,7 +811,7 @@ class UserHandler {
});
}
setup2fa(user, data, callback) {
setupTotp(user, data, callback) {
return this.users.collection('users').findOne({
_id: user
}, {
@ -774,8 +831,10 @@ class UserHandler {
return callback(new Error('Could not find user data'));
}
if (userData.enabled2fa) {
return callback(new Error('2FA is already enabled for this user'));
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
if (enabled2fa.includes('totp')) {
return callback(new Error('TOTP 2FA is already enabled for this user'));
}
if (!data.fresh && userData.seed) {
@ -818,7 +877,7 @@ class UserHandler {
return this.users.collection('users').findOneAndUpdate({
_id: user,
enabled2fa: false
enabled2fa: { $not: { $eq: 'totp' } }
}, {
$set: {
seed
@ -849,7 +908,7 @@ class UserHandler {
return this.logAuthEvent(
user,
{
action: 'new 2fa seed',
action: 'new 2fa totp seed',
ip: data.ip
},
() => callback(null, data_url)
@ -859,7 +918,7 @@ class UserHandler {
});
}
enable2fa(user, data, callback) {
enableTotp(user, data, callback) {
this.users.collection('users').findOne({
_id: user
}, {
@ -871,7 +930,7 @@ class UserHandler {
}, (err, userData) => {
if (err) {
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
err.message = 'Database Error, failed to update user';
err.message = 'Database Error, failed to fetch user';
return callback(err);
}
if (!userData) {
@ -879,15 +938,17 @@ class UserHandler {
return callback(err);
}
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
if (!userData.seed) {
// 2fa not set up
let err = new Error('2FA is not initialized for this user');
let err = new Error('TOTP 2FA is not initialized for this user');
return callback(err);
}
if (userData.enabled2fa) {
if (enabled2fa.includes('totp')) {
// 2fa not set up
let err = new Error('2FA is already enabled for this user');
let err = new Error('TOTP 2FA is already enabled for this user');
return callback(err);
}
@ -917,15 +978,24 @@ class UserHandler {
);
}
let update =
!userData.enabled2fa || typeof userData.enabled2fa === 'boolean'
? {
$set: {
enabled2fa: ['totp']
}
}
: {
$addToSet: {
enabled2fa: 'totp'
}
};
// token was valid, update user settings
return this.users.collection('users').findOneAndUpdate({
_id: user,
seed: userData.seed
}, {
$set: {
enabled2fa: true
}
}, {}, (err, result) => {
}, update, {}, (err, result) => {
if (err) {
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
err.message = 'Database Error, failed to update user';
@ -939,7 +1009,7 @@ class UserHandler {
return this.logAuthEvent(
user,
{
action: 'enable 2fa',
action: 'enable 2fa totp',
result: 'success',
ip: data.ip
},
@ -949,38 +1019,75 @@ class UserHandler {
});
}
disable2fa(user, data, callback) {
return this.users.collection('users').findOneAndUpdate({
_id: user,
enabled2fa: true
disableTotp(user, data, callback) {
this.users.collection('users').findOne({
_id: user
}, {
$set: {
enabled2fa: false,
seed: ''
fields: {
enabled2fa: true,
username: true,
seed: true
}
}, {}, (err, result) => {
}, (err, userData) => {
if (err) {
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
err.message = 'Database Error, failed to update user';
return callback(err);
}
if (!result || !result.value) {
return callback(new Error('Could not update user, check if 2FA is not already disabled'));
if (!userData) {
let err = new Error('This username does not exist');
return callback(err);
}
return this.logAuthEvent(
user,
{
action: 'disable 2fa',
ip: data.ip
},
() => callback(null, true)
);
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
if (!enabled2fa.includes('totp')) {
return callback(new Error('Could not update user, check if 2FA TOTP is not already disabled'));
}
let update =
!userData.enabled2fa || typeof userData.enabled2fa === 'boolean'
? {
$set: {
enabled2fa: [],
seed: ''
}
}
: {
$pull: {
enabled2fa: 'totp'
},
$set: {
seed: ''
}
};
return this.users.collection('users').findOneAndUpdate({
_id: user
}, update, {}, (err, result) => {
if (err) {
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
err.message = 'Database Error, failed to update user';
return callback(err);
}
if (!result || !result.value) {
return callback(new Error('Could not update user, check if 2FA is not already disabled'));
}
return this.logAuthEvent(
user,
{
action: 'disable 2fa totp',
ip: data.ip
},
() => callback(null, true)
);
});
});
}
check2fa(user, data, callback) {
checkTotp(user, data, callback) {
let rlkey = 'totp:' + user.toString();
this.counters.ttlcounter(rlkey, 0, consts.AUTH_FAILURES, consts.AUTH_WINDOW * 3, (err, res) => {
if (err) {
@ -1024,9 +1131,11 @@ class UserHandler {
return callback(err);
}
if (!userData.seed || !userData.enabled2fa) {
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
if (!userData.seed || !enabled2fa.includes('totp')) {
// 2fa not set up
let err = new Error('2FA is not enabled for this user');
let err = new Error('2FA TOTP is not enabled for this user');
return callback(err);
}
@ -1047,7 +1156,7 @@ class UserHandler {
return this.logAuthEvent(
user,
{
action: '2fa',
action: '2fa totp',
ip: data.ip,
result: verified ? 'success' : 'fail'
},
@ -1063,6 +1172,435 @@ class UserHandler {
});
}
setupU2f(user, data, callback) {
let registrationRequest;
try {
registrationRequest = u2f.request(config.u2f.appId);
} catch (E) {
log.error('U2F', 'U2FFAIL request id=%s error=%s', user, E.message);
}
if (!registrationRequest) {
return callback(null, false);
}
this.users.collection('users').findOne({
_id: user
}, {
fields: {
username: true,
enabled2fa: true,
seed: true
}
}, (err, userData) => {
if (err) {
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
err.message = 'Database Error, failed to check user';
return callback(err);
}
if (!userData) {
return callback(new Error('Could not find user data'));
}
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
if (enabled2fa.includes('u2f')) {
return callback(new Error('U2F 2FA is already enabled for this user'));
}
// store registration request to Redis
this.redis
.multi()
.set('u2f:req:' + user, JSON.stringify(registrationRequest))
.expire('u2f:req:' + user, 1 * 3600)
.exec((err, results) => {
if ((!err && !results) || !results[0]) {
err = new Error('Invalid DB response');
} else if (!err && results && results[0] && results[0][0]) {
err = results[0][0];
}
if (err) {
return callback(err);
}
return this.logAuthEvent(
user,
{
action: 'new u2f session',
ip: data.ip
},
() => callback(null, registrationRequest)
);
});
});
}
enableU2f(user, data, callback) {
this.redis
.multi()
.get('u2f:req:' + user)
.del('u2f:req:' + user)
.exec((err, results) => {
if ((!err && !results) || !results[0]) {
err = new Error('Invalid DB response');
} else if (!err && results && results[0] && results[0][0]) {
err = results[0][0];
}
if (err) {
return callback(err);
}
let registrationRequest = results[0][1];
if (!registrationRequest) {
let err = new Error('U2F 2FA is not initialized for this user');
return callback(err);
}
try {
registrationRequest = JSON.parse(registrationRequest);
} catch (E) {
return callback(new Error('Invalid 2FA data stored'));
}
let registrationResponse = {};
Object.keys(data || {}).forEach(key => {
if (['clientData', 'registrationData', 'version', 'challenge'].includes(key)) {
registrationResponse[key] = data[key];
}
});
let result;
try {
result = u2f.checkRegistration(registrationRequest, registrationResponse);
} catch (E) {
log.error('U2F', 'U2FFAIL checkRegistration id=%s error=%s', user, E.message);
}
if (!result || !result.successful) {
return callback(new Error((result && result.errorMessage) || 'Failed to validate U2F response'));
}
this.users.collection('users').findOne({
_id: user
}, {
fields: {
enabled2fa: true,
username: true,
u2fKeyHandle: true,
u2fPubKey: true
}
}, (err, userData) => {
if (err) {
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
err.message = 'Database Error, failed to fetch user';
return callback(err);
}
if (!userData) {
let err = new Error('This username does not exist');
return callback(err);
}
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
if (enabled2fa.includes('u2f')) {
// 2fa not set up
let err = new Error('U2F 2FA is already enabled for this user');
return callback(err);
}
let curDate = new Date();
let update =
!userData.enabled2fa || typeof userData.enabled2fa === 'boolean'
? {
$set: {
enabled2fa: ['u2f'],
u2fKeyHandle: result.keyHandle,
u2fPubKey: result.publicKey,
u2fCert: result.certificate,
u2fDate: curDate
}
}
: {
$addToSet: {
enabled2fa: 'u2f'
},
$set: {
u2fKeyHandle: result.keyHandle,
u2fPubKey: result.publicKey,
u2fCert: result.certificate,
u2fDate: curDate
}
};
return this.users.collection('users').findOneAndUpdate({
_id: user
}, update, {}, (err, result) => {
if (err) {
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
err.message = 'Database Error, failed to update user';
return callback(err);
}
if (!result || !result.value) {
return callback(new Error('Failed to set up 2FA. User not found'));
}
return this.logAuthEvent(
user,
{
action: 'enable 2fa u2f',
result: 'success',
ip: data.ip
},
() => callback(null, true)
);
});
});
});
}
disableU2f(user, data, callback) {
this.users.collection('users').findOne({
_id: user
}, {
fields: {
enabled2fa: true,
username: true,
u2fKeyHandle: true,
u2fPubKey: true
}
}, (err, userData) => {
if (err) {
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
err.message = 'Database Error, failed to update user';
return callback(err);
}
if (!userData) {
let err = new Error('This username does not exist');
return callback(err);
}
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
if (!enabled2fa.includes('u2f')) {
return callback(new Error('Could not update user, check if U2F 2FA is not already disabled'));
}
let update =
!userData.enabled2fa || typeof userData.enabled2fa === 'boolean'
? {
$set: {
enabled2fa: [],
u2fKeyHandle: '',
u2fPubKey: '',
u2fCert: ''
}
}
: {
$pull: {
enabled2fa: 'u2f'
},
$set: {
u2fKeyHandle: '',
u2fPubKey: '',
u2fCert: ''
}
};
return this.users.collection('users').findOneAndUpdate({
_id: user
}, update, {}, (err, result) => {
if (err) {
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
err.message = 'Database Error, failed to update user';
return callback(err);
}
if (!result || !result.value) {
return callback(new Error('Could not update user, check if 2FA is not already disabled'));
}
return this.logAuthEvent(
user,
{
action: 'disable 2fa u2f',
ip: data.ip
},
() => callback(null, true)
);
});
});
}
startU2f(user, data, callback) {
this.users.collection('users').findOne({
_id: user
}, {
fields: {
enabled2fa: true,
username: true,
u2fKeyHandle: true,
u2fPubKey: true
}
}, (err, userData) => {
if (err) {
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
err.message = 'Database Error, failed to find user';
return callback(err);
}
if (!userData) {
let err = new Error('This user does not exist');
return callback(err);
}
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
if (!enabled2fa.includes('u2f') || !userData.u2fKeyHandle) {
// 2fa not set up
let err = new Error('2FA U2F is not enabled for this user');
return callback(err);
}
this.generateU2fAuthRequest(user, userData.u2fKeyHandle, (err, authRequest) => {
if (err) {
return callback(err);
}
if (!authRequest) {
return callback(null, false);
}
return this.logAuthEvent(
user,
{
action: '2fa start u2f',
ip: data.ip
},
() => {
callback(null, authRequest);
}
);
});
});
}
checkU2f(user, data, callback) {
this.users.collection('users').findOne({
_id: user
}, {
fields: {
enabled2fa: true,
username: true,
u2fKeyHandle: true,
u2fPubKey: true
}
}, (err, userData) => {
if (err) {
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
err.message = 'Database Error, failed to find user';
return callback(err);
}
if (!userData) {
let err = new Error('This user does not exist');
return callback(err);
}
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
if (!enabled2fa.includes('u2f') || !userData.u2fKeyHandle) {
// 2fa not set up
let err = new Error('2FA U2F is not enabled for this user');
return callback(err);
}
this.redis
.multi()
.get('u2f:auth:' + user)
.del('u2f:auth:' + user)
.exec((err, results) => {
if ((!err && !results) || !results[0]) {
err = new Error('Invalid DB response');
} else if (!err && results && results[0] && results[0][0]) {
err = results[0][0];
}
if (err) {
return callback(err);
}
let authRequest = results[0][1];
if (!authRequest) {
return callback(null, false);
}
try {
authRequest = JSON.parse(authRequest);
} catch (E) {
return callback(null, false);
}
let authResponse = {};
Object.keys(data || {}).forEach(key => {
if (['clientData', 'signatureData'].includes(key)) {
authResponse[key] = data[key];
}
});
let result;
try {
result = u2f.checkSignature(authRequest, authResponse, userData.u2fPubKey);
} catch (E) {
// ignore
log.error('U2F', 'U2FFAIL checkSignature id=%s error=%s', user, E.message);
}
let verified = result && result.successful;
return this.logAuthEvent(
user,
{
action: '2fa u2f',
ip: data.ip,
result: verified ? 'success' : 'fail'
},
() => {
callback(null, verified);
}
);
});
});
}
disable2fa(user, data, callback) {
this.users.collection('users').findOneAndUpdate({
_id: user
}, {
$set: {
enabled2fa: [],
seed: '',
u2FKeyHandle: '',
u2fPubKey: ''
}
}, {}, (err, result) => {
if (err) {
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
err.message = 'Database Error, failed to update user';
return callback(err);
}
if (!result || !result.value) {
return callback(new Error('Could not update user, check if 2FA is not already disabled'));
}
return this.logAuthEvent(
user,
{
action: 'disable 2fa',
ip: data.ip
},
() => callback(null, true)
);
});
}
update(user, data, callback) {
let $set = {};
let updates = false;

View file

@ -53,6 +53,7 @@
"smtp-server": "3.3.0",
"speakeasy": "2.0.0",
"tlds": "1.197.0",
"u2f": "^0.1.2",
"utf7": "1.0.2",
"uuid": "3.1.0",
"wild-config": "1.3.5"