|
@@ -0,0 +1,329 @@
|
|
|
+import { Meteor } from 'meteor/meteor';
|
|
|
+import { Accounts } from 'meteor/accounts-base';
|
|
|
+import _AccountsLockoutCollection from './accountsLockoutCollection';
|
|
|
+
|
|
|
+class UnknownUser {
|
|
|
+ constructor(
|
|
|
+ settings,
|
|
|
+ {
|
|
|
+ AccountsLockoutCollection = _AccountsLockoutCollection,
|
|
|
+ } = {},
|
|
|
+ ) {
|
|
|
+ this.AccountsLockoutCollection = AccountsLockoutCollection;
|
|
|
+ this.settings = settings;
|
|
|
+ }
|
|
|
+
|
|
|
+ startup() {
|
|
|
+ if (!(this.settings instanceof Function)) {
|
|
|
+ this.updateSettings();
|
|
|
+ }
|
|
|
+ this.scheduleUnlocksForLockedAccounts();
|
|
|
+ this.unlockAccountsIfLockoutAlreadyExpired();
|
|
|
+ this.hookIntoAccounts();
|
|
|
+ }
|
|
|
+
|
|
|
+ updateSettings() {
|
|
|
+ const settings = UnknownUser.unknownUsers();
|
|
|
+ if (settings) {
|
|
|
+ settings.forEach(function updateSetting({ key, value }) {
|
|
|
+ this.settings[key] = value;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ this.validateSettings();
|
|
|
+ }
|
|
|
+
|
|
|
+ validateSettings() {
|
|
|
+ if (
|
|
|
+ !this.settings.failuresBeforeLockout ||
|
|
|
+ this.settings.failuresBeforeLockout < 0
|
|
|
+ ) {
|
|
|
+ throw new Error('"failuresBeforeLockout" is not positive integer');
|
|
|
+ }
|
|
|
+ if (
|
|
|
+ !this.settings.lockoutPeriod ||
|
|
|
+ this.settings.lockoutPeriod < 0
|
|
|
+ ) {
|
|
|
+ throw new Error('"lockoutPeriod" is not positive integer');
|
|
|
+ }
|
|
|
+ if (
|
|
|
+ !this.settings.failureWindow ||
|
|
|
+ this.settings.failureWindow < 0
|
|
|
+ ) {
|
|
|
+ throw new Error('"failureWindow" is not positive integer');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ scheduleUnlocksForLockedAccounts() {
|
|
|
+ const lockedAccountsCursor = this.AccountsLockoutCollection.find(
|
|
|
+ {
|
|
|
+ 'services.accounts-lockout.unlockTime': {
|
|
|
+ $gt: Number(new Date()),
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ fields: {
|
|
|
+ 'services.accounts-lockout.unlockTime': 1,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ );
|
|
|
+ const currentTime = Number(new Date());
|
|
|
+ lockedAccountsCursor.forEach((connection) => {
|
|
|
+ let lockDuration = this.unlockTime(connection) - currentTime;
|
|
|
+ if (lockDuration >= this.settings.lockoutPeriod) {
|
|
|
+ lockDuration = this.settings.lockoutPeriod * 1000;
|
|
|
+ }
|
|
|
+ if (lockDuration <= 1) {
|
|
|
+ lockDuration = 1;
|
|
|
+ }
|
|
|
+ Meteor.setTimeout(
|
|
|
+ this.unlockAccount.bind(this, connection.clientAddress),
|
|
|
+ lockDuration,
|
|
|
+ );
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ unlockAccountsIfLockoutAlreadyExpired() {
|
|
|
+ const currentTime = Number(new Date());
|
|
|
+ const query = {
|
|
|
+ 'services.accounts-lockout.unlockTime': {
|
|
|
+ $lt: currentTime,
|
|
|
+ },
|
|
|
+ };
|
|
|
+ const data = {
|
|
|
+ $unset: {
|
|
|
+ 'services.accounts-lockout.unlockTime': 0,
|
|
|
+ 'services.accounts-lockout.failedAttempts': 0,
|
|
|
+ },
|
|
|
+ };
|
|
|
+ this.AccountsLockoutCollection.update(query, data);
|
|
|
+ }
|
|
|
+
|
|
|
+ hookIntoAccounts() {
|
|
|
+ Accounts.validateLoginAttempt(this.validateLoginAttempt.bind(this));
|
|
|
+ Accounts.onLogin(this.onLogin.bind(this));
|
|
|
+ }
|
|
|
+
|
|
|
+ validateLoginAttempt(loginInfo) {
|
|
|
+ // don't interrupt non-password logins
|
|
|
+ if (
|
|
|
+ loginInfo.type !== 'password' ||
|
|
|
+ loginInfo.user !== undefined ||
|
|
|
+ loginInfo.error === undefined ||
|
|
|
+ loginInfo.error.reason !== 'User not found'
|
|
|
+ ) {
|
|
|
+ return loginInfo.allowed;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.settings instanceof Function) {
|
|
|
+ this.settings = this.settings(loginInfo.connection);
|
|
|
+ this.validateSettings();
|
|
|
+ }
|
|
|
+
|
|
|
+ const clientAddress = loginInfo.connection.clientAddress;
|
|
|
+ const unlockTime = this.unlockTime(loginInfo.connection);
|
|
|
+ let failedAttempts = 1 + this.failedAttempts(loginInfo.connection);
|
|
|
+ const firstFailedAttempt = this.firstFailedAttempt(loginInfo.connection);
|
|
|
+ const currentTime = Number(new Date());
|
|
|
+
|
|
|
+ const canReset = (currentTime - firstFailedAttempt) > (1000 * this.settings.failureWindow);
|
|
|
+ if (canReset) {
|
|
|
+ failedAttempts = 1;
|
|
|
+ this.resetAttempts(failedAttempts, clientAddress);
|
|
|
+ }
|
|
|
+
|
|
|
+ const canIncrement = failedAttempts < this.settings.failuresBeforeLockout;
|
|
|
+ if (canIncrement) {
|
|
|
+ this.incrementAttempts(failedAttempts, clientAddress);
|
|
|
+ }
|
|
|
+
|
|
|
+ const maxAttemptsAllowed = this.settings.failuresBeforeLockout;
|
|
|
+ const attemptsRemaining = maxAttemptsAllowed - failedAttempts;
|
|
|
+ if (unlockTime > currentTime) {
|
|
|
+ let duration = unlockTime - currentTime;
|
|
|
+ duration = Math.ceil(duration / 1000);
|
|
|
+ duration = duration > 1 ? duration : 1;
|
|
|
+ UnknownUser.tooManyAttempts(duration);
|
|
|
+ }
|
|
|
+ if (failedAttempts === maxAttemptsAllowed) {
|
|
|
+ this.setNewUnlockTime(failedAttempts, clientAddress);
|
|
|
+
|
|
|
+ let duration = this.settings.lockoutPeriod;
|
|
|
+ duration = Math.ceil(duration);
|
|
|
+ duration = duration > 1 ? duration : 1;
|
|
|
+ return UnknownUser.tooManyAttempts(duration);
|
|
|
+ }
|
|
|
+ return UnknownUser.userNotFound(
|
|
|
+ failedAttempts,
|
|
|
+ maxAttemptsAllowed,
|
|
|
+ attemptsRemaining,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ resetAttempts(
|
|
|
+ failedAttempts,
|
|
|
+ clientAddress,
|
|
|
+ ) {
|
|
|
+ const currentTime = Number(new Date());
|
|
|
+ const query = { clientAddress };
|
|
|
+ const data = {
|
|
|
+ $set: {
|
|
|
+ 'services.accounts-lockout.failedAttempts': failedAttempts,
|
|
|
+ 'services.accounts-lockout.lastFailedAttempt': currentTime,
|
|
|
+ 'services.accounts-lockout.firstFailedAttempt': currentTime,
|
|
|
+ },
|
|
|
+ };
|
|
|
+ this.AccountsLockoutCollection.upsert(query, data);
|
|
|
+ }
|
|
|
+
|
|
|
+ incrementAttempts(
|
|
|
+ failedAttempts,
|
|
|
+ clientAddress,
|
|
|
+ ) {
|
|
|
+ const currentTime = Number(new Date());
|
|
|
+ const query = { clientAddress };
|
|
|
+ const data = {
|
|
|
+ $set: {
|
|
|
+ 'services.accounts-lockout.failedAttempts': failedAttempts,
|
|
|
+ 'services.accounts-lockout.lastFailedAttempt': currentTime,
|
|
|
+ },
|
|
|
+ };
|
|
|
+ this.AccountsLockoutCollection.upsert(query, data);
|
|
|
+ }
|
|
|
+
|
|
|
+ setNewUnlockTime(
|
|
|
+ failedAttempts,
|
|
|
+ clientAddress,
|
|
|
+ ) {
|
|
|
+ const currentTime = Number(new Date());
|
|
|
+ const newUnlockTime = (1000 * this.settings.lockoutPeriod) + currentTime;
|
|
|
+ const query = { clientAddress };
|
|
|
+ const data = {
|
|
|
+ $set: {
|
|
|
+ 'services.accounts-lockout.failedAttempts': failedAttempts,
|
|
|
+ 'services.accounts-lockout.lastFailedAttempt': currentTime,
|
|
|
+ 'services.accounts-lockout.unlockTime': newUnlockTime,
|
|
|
+ },
|
|
|
+ };
|
|
|
+ this.AccountsLockoutCollection.upsert(query, data);
|
|
|
+ Meteor.setTimeout(
|
|
|
+ this.unlockAccount.bind(this, clientAddress),
|
|
|
+ this.settings.lockoutPeriod * 1000,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ onLogin(loginInfo) {
|
|
|
+ if (loginInfo.type !== 'password') {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const clientAddress = loginInfo.connection.clientAddress;
|
|
|
+ const query = { clientAddress };
|
|
|
+ const data = {
|
|
|
+ $unset: {
|
|
|
+ 'services.accounts-lockout.unlockTime': 0,
|
|
|
+ 'services.accounts-lockout.failedAttempts': 0,
|
|
|
+ },
|
|
|
+ };
|
|
|
+ this.AccountsLockoutCollection.update(query, data);
|
|
|
+ }
|
|
|
+
|
|
|
+ static userNotFound(
|
|
|
+ failedAttempts,
|
|
|
+ maxAttemptsAllowed,
|
|
|
+ attemptsRemaining,
|
|
|
+ ) {
|
|
|
+ throw new Meteor.Error(
|
|
|
+ 403,
|
|
|
+ 'User not found',
|
|
|
+ JSON.stringify({
|
|
|
+ message: 'User not found',
|
|
|
+ failedAttempts,
|
|
|
+ maxAttemptsAllowed,
|
|
|
+ attemptsRemaining,
|
|
|
+ }),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ static tooManyAttempts(duration) {
|
|
|
+ throw new Meteor.Error(
|
|
|
+ 403,
|
|
|
+ 'Too many attempts',
|
|
|
+ JSON.stringify({
|
|
|
+ message: 'Wrong emails were submitted too many times. Account is locked for a while.',
|
|
|
+ duration,
|
|
|
+ }),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ static unknownUsers() {
|
|
|
+ let unknownUsers;
|
|
|
+ try {
|
|
|
+ unknownUsers = Meteor.settings['accounts-lockout'].unknownUsers;
|
|
|
+ } catch (e) {
|
|
|
+ unknownUsers = false;
|
|
|
+ }
|
|
|
+ return unknownUsers || false;
|
|
|
+ }
|
|
|
+
|
|
|
+ findOneByConnection(connection) {
|
|
|
+ return this.AccountsLockoutCollection.findOne({
|
|
|
+ clientAddress: connection.clientAddress,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ unlockTime(connection) {
|
|
|
+ connection = this.findOneByConnection(connection);
|
|
|
+ let unlockTime;
|
|
|
+ try {
|
|
|
+ unlockTime = connection.services['accounts-lockout'].unlockTime;
|
|
|
+ } catch (e) {
|
|
|
+ unlockTime = 0;
|
|
|
+ }
|
|
|
+ return unlockTime || 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ failedAttempts(connection) {
|
|
|
+ connection = this.findOneByConnection(connection);
|
|
|
+ let failedAttempts;
|
|
|
+ try {
|
|
|
+ failedAttempts = connection.services['accounts-lockout'].failedAttempts;
|
|
|
+ } catch (e) {
|
|
|
+ failedAttempts = 0;
|
|
|
+ }
|
|
|
+ return failedAttempts || 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ lastFailedAttempt(connection) {
|
|
|
+ connection = this.findOneByConnection(connection);
|
|
|
+ let lastFailedAttempt;
|
|
|
+ try {
|
|
|
+ lastFailedAttempt = connection.services['accounts-lockout'].lastFailedAttempt;
|
|
|
+ } catch (e) {
|
|
|
+ lastFailedAttempt = 0;
|
|
|
+ }
|
|
|
+ return lastFailedAttempt || 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ firstFailedAttempt(connection) {
|
|
|
+ connection = this.findOneByConnection(connection);
|
|
|
+ let firstFailedAttempt;
|
|
|
+ try {
|
|
|
+ firstFailedAttempt = connection.services['accounts-lockout'].firstFailedAttempt;
|
|
|
+ } catch (e) {
|
|
|
+ firstFailedAttempt = 0;
|
|
|
+ }
|
|
|
+ return firstFailedAttempt || 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ unlockAccount(clientAddress) {
|
|
|
+ const query = { clientAddress };
|
|
|
+ const data = {
|
|
|
+ $unset: {
|
|
|
+ 'services.accounts-lockout.unlockTime': 0,
|
|
|
+ 'services.accounts-lockout.failedAttempts': 0,
|
|
|
+ },
|
|
|
+ };
|
|
|
+ this.AccountsLockoutCollection.update(query, data);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export default UnknownUser;
|