diff --git a/migrations/20210430120936-users-add-locationsJson.js b/migrations/20210430120936-users-add-locationsJson.js new file mode 100644 index 000000000..e53947eb9 --- /dev/null +++ b/migrations/20210430120936-users-add-locationsJson.js @@ -0,0 +1,15 @@ +'use strict'; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE users ADD COLUMN locationJson TEXT', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE users DROP COLUMN locationJson', function (error) { + if (error) console.error(error); + callback(error); + }); +}; diff --git a/migrations/schema.sql b/migrations/schema.sql index e197c03bc..b2b026f1c 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS users( resetTokenCreationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, active BOOLEAN DEFAULT 1, avatar MEDIUMBLOB, + locationJson TEXT, // { locations: [{ ip, userAgent, city, country, ts }] } PRIMARY KEY(id)); diff --git a/src/mailer.js b/src/mailer.js index ee020d49a..ed8ada497 100644 --- a/src/mailer.js +++ b/src/mailer.js @@ -142,9 +142,12 @@ function sendInvite(user, invitor, inviteLink) { }); } -function sendNewLoginLocation(user, newLocation) { +function sendNewLoginLocation(user, ip, userAgent, country, city) { assert.strictEqual(typeof user, 'object'); - assert.strictEqual(typeof newLocation, 'string'); + assert.strictEqual(typeof ip, 'string'); + assert.strictEqual(typeof userAgent, 'string'); + assert.strictEqual(typeof country, 'string'); + assert.strictEqual(typeof city, 'string'); debug('Sending new login location mail'); diff --git a/src/routes/cloudron.js b/src/routes/cloudron.js index d7aa07580..8f470229b 100644 --- a/src/routes/cloudron.js +++ b/src/routes/cloudron.js @@ -34,7 +34,6 @@ let assert = require('assert'), externalLdap = require('../externalldap.js'), HttpError = require('connect-lastmile').HttpError, HttpSuccess = require('connect-lastmile').HttpSuccess, - mailer = require('../mailer.js'), sysinfo = require('../sysinfo.js'), system = require('../system.js'), tokendb = require('../tokendb.js'), @@ -51,6 +50,7 @@ function login(req, res, next) { const type = req.body.type || tokens.ID_WEBADMIN; const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress || null; + const userAgent = req.headers['user-agent'] || ''; const auditSource = { authType: 'basic', ip: ip }; const error = tokens.validateTokenType(type); @@ -59,15 +59,11 @@ function login(req, res, next) { tokens.add(type, req.user.id, Date.now() + constants.DEFAULT_TOKEN_EXPIRATION, {}, function (error, token) { if (error) return next(new HttpError(500, error)); - eventlog.getAllPaged([ eventlog.ACTION_USER_LOGIN ], ip, 1, 100, function (error, result) { - if (error) console.error(error); + eventlog.add(eventlog.ACTION_USER_LOGIN, auditSource, { userId: req.user.id, user: users.removePrivateFields(req.user) }); - if (!error && result.length === 0) mailer.sendNewLoginLocation(req.user, ip); + users.checkLoginLocation(req.user, ip, userAgent); - eventlog.add(eventlog.ACTION_USER_LOGIN, auditSource, { userId: req.user.id, user: users.removePrivateFields(req.user) }); - - next(new HttpSuccess(200, token)); - }); + next(new HttpSuccess(200, token)); }); } diff --git a/src/userdb.js b/src/userdb.js index 5590e63da..cc18e2b3d 100644 --- a/src/userdb.js +++ b/src/userdb.js @@ -27,11 +27,12 @@ exports = module.exports = { var assert = require('assert'), BoxError = require('./boxerror.js'), database = require('./database.js'), - mysql = require('mysql'); + mysql = require('mysql'), + safe = require('safetydance'); // the avatar field is special and not added here to reduce response sizes const USERS_FIELDS = [ 'id', 'username', 'email', 'fallbackEmail', 'password', 'salt', 'createdAt', 'resetToken', 'displayName', - 'twoFactorAuthenticationEnabled', 'twoFactorAuthenticationSecret', 'active', 'source', 'role', 'resetTokenCreationTime' ].join(','); + 'twoFactorAuthenticationEnabled', 'twoFactorAuthenticationSecret', 'active', 'source', 'role', 'resetTokenCreationTime', 'locationJson' ].join(','); const APP_PASSWORD_FIELDS = [ 'id', 'name', 'userId', 'identifier', 'hashedPassword', 'creationTime' ].join(','); @@ -41,6 +42,11 @@ function postProcess(result) { result.twoFactorAuthenticationEnabled = !!result.twoFactorAuthenticationEnabled; result.active = !!result.active; + // we remove the JSON first locations property, it is only there to have a valid JSON, no toplevel array + const tmp = safe.JSON.parse(result.locationJson) || { locations: [] }; + result.locations = tmp.locations || []; + delete result.locationJson; + return result; } @@ -238,6 +244,7 @@ function update(userId, user, callback) { assert(!('twoFactorAuthenticationEnabled' in user) || (typeof user.twoFactorAuthenticationEnabled === 'boolean')); assert(!('role' in user) || (typeof user.role === 'string')); assert(!('active' in user) || (typeof user.active === 'boolean')); + assert(!('locations' in user) || (Array.isArray(user.locations))); var args = [ ]; var fields = [ ]; @@ -245,6 +252,9 @@ function update(userId, user, callback) { if (k === 'twoFactorAuthenticationEnabled' || k === 'active') { fields.push(k + ' = ?'); args.push(user[k] ? 1 : 0); + } else if (k === 'locations') { + fields.push('locationJson = ?'); + args.push(JSON.stringify({ locations: user[k] || []})); } else { fields.push(k + ' = ?'); args.push(user[k]); diff --git a/src/users.js b/src/users.js index 1ebdc9ecc..475ec6b0c 100644 --- a/src/users.js +++ b/src/users.js @@ -30,6 +30,8 @@ exports = module.exports = { sendPasswordResetByIdentifier, + checkLoginLocation, + setupAccount, getAvatarUrl, setAvatar, @@ -72,6 +74,7 @@ let assert = require('assert'), tokens = require('./tokens.js'), userdb = require('./userdb.js'), uuid = require('uuid'), + superagent = require('superagent'), validator = require('validator'), _ = require('underscore'); @@ -527,6 +530,38 @@ function sendPasswordResetByIdentifier(identifier, callback) { }); } +function checkLoginLocation(user, ip, userAgent) { + assert.strictEqual(typeof user, 'object'); + assert.strictEqual(typeof ip, 'string'); + assert.strictEqual(typeof userAgent, 'string'); + + debug(`checkLoginLocation: ${user.id} ${ip} ${userAgent}`); + + superagent.get('https://geolocation.cloudron.io/json').query({ ip: ip }).end(function (error, result) { + if (error) return console.error('Failed to get geoip info:', error); + + const country = result.body.country.names.en; + const city = result.body.city.names.en; + + const knownLogin = user.locations.find(function (l) { + return l.userAgent === userAgent && l.country === country && l.city === city; + }); + + if (knownLogin) return; + + // purge potentially old locations where ts > now() - 6 months + const sixMonthsBack = Date.now() - 6 * 30 * 24 * 60 * 60 * 1000; + var locations = user.locations.filter(function (l) { return l.ts > sixMonthsBack; }); + + locations.push({ ts: Date.now(), ip, userAgent, country, city }); + userdb.update(user.id, { locations }, function (error) { + if (error) console.error('checkLoginLocation: Failed to update user location.', error); + + mailer.sendNewLoginLocation(user, ip, userAgent, country, city); + }); + }); +} + function setPassword(user, newPassword, callback) { assert.strictEqual(typeof user, 'object'); assert.strictEqual(typeof newPassword, 'string');