Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NEW] Add ability to block failed login attempts by user and IP #17783

Merged
merged 14 commits into from
Jun 20, 2020
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions app/authentication/server/ILoginAttempt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { IUser } from '../../../definition/IUser';
import { IMethodConnection } from '../../../definition/IMethodThisType';

interface IMethodArgument {
user?: { username: string };
password?: {
digest: string;
algorithm: string;
};
resume?: string;
}

export interface ILoginAttempt {
type: string;
allowed: boolean;
methodName: string;
methodArguments: IMethodArgument[];
connection: IMethodConnection;
user?: IUser;
}
1 change: 1 addition & 0 deletions app/authentication/server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './startup';
27 changes: 27 additions & 0 deletions app/authentication/server/lib/logLoginAttempts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ILoginAttempt } from '../ILoginAttempt';
import { settings } from '../../../settings/server';

export const logFailedLoginAttemps = (login: ILoginAttempt): void => {
if (!settings.get('Accounts_FailedLoginAudit_Enabled')) {
return;
}

let user = 'unknown';
if (login.methodArguments[0]?.user?.username && settings.get('Accounts_FailedLoginAudit_Log_Username')) {
user = login.methodArguments[0]?.user?.username;
}
const { connection } = login;
let { clientAddress } = connection;
if (!settings.get('Accounts_FailedLoginAudit_Log_ClientIp')) {
clientAddress = '-';
}
let forwardedFor = connection.httpHeaders['x-forwarded-for'];
if (!settings.get('Accounts_FailedLoginAudit_Log_ForwardedForIp')) {
forwardedFor = '-';
}
let userAgent = connection.httpHeaders['user-agent'];
if (!settings.get('Accounts_FailedLoginAudit_Log_UserAgent')) {
userAgent = '-';
}
console.log('Failed login detected - Username[%s] ClientAddress[%s] ForwardedFor[%s] UserAgent[%s]', user, clientAddress, forwardedFor, userAgent);
};
79 changes: 79 additions & 0 deletions app/authentication/server/lib/restrictLoginAttempts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import moment from 'moment';

import { ILoginAttempt } from '../ILoginAttempt';
import { ServerEvents, Users } from '../../../models/server/raw';
import { IServerEventType } from '../../../../definition/IServerEvent';
import { IUser } from '../../../../definition/IUser';
import { settings } from '../../../settings/server';
import { addMinutesToADate } from '../../../utils/lib/date.helper';
import Sessions from '../../../models/server/raw/Sessions';

export const isValidLoginAttemptByIp = async (ip: string): Promise<boolean> => {
const whitelist = String(settings.get('Accounts_Block_Failed_Attempts_Ip_Whitelist')).split(',');
if (!settings.get('Accounts_Block_Failed_Login_Attempts_By_Ip') || whitelist.includes(ip)) {
return true;
}
const lastLogin = await Sessions.findLastLoginByIp(ip);
let failedAttemptsSinceLastLogin;
if (!lastLogin) {
failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByIp(ip);
} else {
failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByIpSince(ip, new Date(lastLogin.loginAt));
}
const attemptsUntilBlock = settings.get('Accounts_Block_Failed_Attempts_Until_Block_By_Ip');
if (attemptsUntilBlock && failedAttemptsSinceLastLogin < attemptsUntilBlock) {
return true;
}
const lastAttemptAt = (await ServerEvents.findLastFailedAttemptByIp(ip)).ts;
const minutesUntilUnblock = settings.get('Accounts_Block_Failed_Attempts_Time_To_Unblock_By_Ip_In_Minutes') as number;
const willBeBlockedUntil = addMinutesToADate(new Date(lastAttemptAt), minutesUntilUnblock);
return moment(new Date()).isSameOrAfter(willBeBlockedUntil);
};

export const isValidAttemptByUser = async (login: ILoginAttempt): Promise<boolean> => {
if (!settings.get('Accounts_Block_Failed_Login_Attempts_By_User')) {
return true;
}
const user = await Users.findOneByUsername(login.methodArguments[0].user?.username) || login.user;
if (!user) {
return true;
}
let failedAttemptsSinceLastLogin;
if (!user?.lastLogin) {
failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByUsername(user.username);
} else {
failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByUsernameSince(user.username, new Date(user.lastLogin));
}
const attemptsUntilBlock = settings.get('Accounts_Block_Failed_Attempts_Until_Block_By_User');
if (attemptsUntilBlock && failedAttemptsSinceLastLogin < attemptsUntilBlock) {
return true;
}
const lastAttemptAt = (await ServerEvents.findLastFailedAttemptByUsername(user.username as string)).ts;
const minutesUntilUnblock = settings.get('Accounts_Block_Failed_Attempts_Time_To_Unblock_By_User_In_Minutes') as number;
const willBeBlockedUntil = addMinutesToADate(new Date(lastAttemptAt), minutesUntilUnblock);
return moment(new Date()).isSameOrAfter(willBeBlockedUntil);
};

export const saveFailedLoginAttempts = async (login: ILoginAttempt): Promise<void> => {
const user: any = {
username: login.user?.username || login.methodArguments[0].user?.username,
};
if (login.user?._id) {
user._id = login.user._id;
}
await ServerEvents.insertOne({
ip: login.connection.clientAddress,
t: IServerEventType.FAILED_LOGIN_ATTEMPT,
ts: new Date(),
u: user as IUser,
});
};

export const saveSuccessfulLogin = async (login: ILoginAttempt): Promise<void> => {
await ServerEvents.insertOne({
ip: login.connection.clientAddress,
t: IServerEventType.LOGIN,
ts: new Date(),
u: login.user,
});
};
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { Meteor } from 'meteor/meteor';
import { Match } from 'meteor/check';
import { Accounts } from 'meteor/accounts-base';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import _ from 'underscore';
import s from 'underscore.string';

import * as Mailer from '../../app/mailer';
import { settings } from '../../app/settings';
import { callbacks } from '../../app/callbacks';
import { Roles, Users, Settings } from '../../app/models';
import { Users as UsersRaw } from '../../app/models/server/raw';
import { addUserRoles } from '../../app/authorization';
import { getAvatarSuggestionForUser } from '../../app/lib/server/functions';
import * as Mailer from '../../../mailer/server/api';
import { settings } from '../../../settings/server';
import { callbacks } from '../../../callbacks/server';
import { Roles, Users, Settings } from '../../../models/server';
import { Users as UsersRaw } from '../../../models/server/raw';
import { addUserRoles } from '../../../authorization/server';
import { getAvatarSuggestionForUser } from '../../../lib/server/functions';
import {
isValidAttemptByUser,
saveFailedLoginAttempts,
saveSuccessfulLogin,
isValidLoginAttemptByIp,
} from '../lib/restrictLoginAttempts';
import { ILoginAttempt } from '../ILoginAttempt';
import './settings';
import { logFailedLoginAttemps } from '../lib/logLoginAttempts';

Accounts.config({
forbidClientAccountCreation: true,
Expand Down Expand Up @@ -84,6 +94,7 @@ Meteor.startup(() => {
});

Accounts.emailTemplates.verifyEmail.html = function(userModel, url) {
// eslint-disable-next-line @typescript-eslint/camelcase
return Mailer.replace(verifyEmailTemplate, { Verification_Url: url, name: userModel.name });
};

Expand Down Expand Up @@ -288,8 +299,18 @@ Accounts.insertUserDoc = _.wrap(Accounts.insertUserDoc, function(insertUserDoc,
return _id;
});

Accounts.validateLoginAttempt(function(login) {
Accounts.validateLoginAttempt(function(login: ILoginAttempt) {
login = callbacks.run('beforeValidateLogin', login);
if (!Promise.await(isValidLoginAttemptByIp(login.connection.clientAddress))) {
throw new Meteor.Error('error-login-blocked-by-ip', 'Login has been temporarily blocked By IP', {
function: 'Accounts.validateLoginAttempt',
});
}
if (!Promise.await(isValidAttemptByUser(login))) {
throw new Meteor.Error('error-login-blocked-by-user', 'Login has been temporarily blocked By User', {
function: 'Accounts.validateLoginAttempt',
});
}

if (login.allowed !== true) {
return login.allowed;
Expand Down Expand Up @@ -385,3 +406,9 @@ Accounts.onLogin(async ({ user }) => {
Users.removeOlderResumeTokensByUserId(user._id, oldestDate.when);
}
});

Accounts.onLoginFailure((login: ILoginAttempt) => {
saveFailedLoginAttempts(login);
logFailedLoginAttemps(login);
});
callbacks.add('afterValidateLogin', (login: ILoginAttempt) => saveSuccessfulLogin(login));
35 changes: 35 additions & 0 deletions app/authentication/server/startup/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Meteor } from 'meteor/meteor';

import { settings } from '../../../settings/server';

Meteor.startup(function() {
settings.addGroup('Accounts', function() {
this.section('Failed_Login_Attempts', function() {
this.add('Accounts_Block_Failed_Login_Attempts_By_User', true, { type: 'boolean' });
this.add('Accounts_Block_Failed_Attempts_Until_Block_By_User', 10, { type: 'int' });
this.add('Accounts_Block_Failed_Attempts_Time_To_Unblock_By_User_In_Minutes', 5, { type: 'int' });
this.add('Accounts_Block_Failed_Login_Attempts_By_Ip', true, { type: 'boolean' });
this.add('Accounts_Block_Failed_Attempts_Until_Block_By_Ip', 10, { type: 'int' });
rodrigok marked this conversation as resolved.
Show resolved Hide resolved
this.add('Accounts_Block_Failed_Attempts_Time_To_Unblock_By_Ip_In_Minutes', 5, { type: 'int' });
this.add('Accounts_Block_Failed_Attempts_Ip_Whitelist', '', { type: 'string' });

this.add('Accounts_FailedLoginAudit_Enabled', false, { type: 'boolean' });
this.add('Accounts_FailedLoginAudit_Log_Username', false, {
type: 'boolean',
enableQuery: { _id: 'Accounts_FailedLoginAudit_Enabled', value: true },
});
this.add('Accounts_FailedLoginAudit_Log_UserAgent', false, {
type: 'boolean',
enableQuery: { _id: 'Accounts_FailedLoginAudit_Enabled', value: true },
});
this.add('Accounts_FailedLoginAudit_Log_ClientIp', false, {
type: 'boolean',
enableQuery: { _id: 'Accounts_FailedLoginAudit_Enabled', value: true },
});
this.add('Accounts_FailedLoginAudit_Log_ForwardedForIp', false, {
type: 'boolean',
enableQuery: { _id: 'Accounts_FailedLoginAudit_Enabled', value: true },
});
});
});
});
11 changes: 11 additions & 0 deletions app/models/server/definition/IServerEventsRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { IServerEvent } from '../../../../definition/IServerEvent';

export interface IServerEventsRepository {
sampaiodiego marked this conversation as resolved.
Show resolved Hide resolved
insertOne(data: Omit<IServerEvent, '_id'>): Promise<any>;
countFailedAttemptsByUsername(username: string): Promise<number>;
countFailedAttemptsByUsernameSince(username: string, since: Date): Promise<number>;
countFailedAttemptsByIpSince(ip: string, since: Date): Promise<number>;
countFailedAttemptsByIp(ip: string): Promise<number>;
findLastFailedAttemptByUsername(username: string): Promise<IServerEvent>;
findLastFailedAttemptByIp(ip: string): Promise<IServerEvent>;
}
10 changes: 10 additions & 0 deletions app/models/server/models/ServerEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Base } from './_Base';

export class ServerEvents extends Base {
constructor() {
super('server_events');
this.tryEnsureIndex({ ip: 1, ts: 1 });
rodrigok marked this conversation as resolved.
Show resolved Hide resolved
MarcosSpessatto marked this conversation as resolved.
Show resolved Hide resolved
}
}

export default new ServerEvents();
68 changes: 68 additions & 0 deletions app/models/server/raw/ServerEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Collection, ObjectId } from 'mongodb';

import { BaseRaw } from './BaseRaw';
import { IServerEvent, IServerEventType } from '../../../../definition/IServerEvent';
import { IServerEventsRepository } from '../definition/IServerEventsRepository';
import { IUser } from '../../../../definition/IUser';

export class ServerEventsRaw extends BaseRaw implements IServerEventsRepository {
public readonly col!: Collection<IServerEvent>;

async insertOne(data: Omit<IServerEvent, '_id'>): Promise<any> {
if (data.u) {
data.u = { _id: data.u._id, username: data.u.username } as IUser;
}
return this.col.insertOne({
_id: new ObjectId().toHexString(),
...data,
});
}

async findLastFailedAttemptByIp(ip: string): Promise<IServerEvent> {
return (await this.col.find({
MarcosSpessatto marked this conversation as resolved.
Show resolved Hide resolved
ip,
t: IServerEventType.FAILED_LOGIN_ATTEMPT,
}, { sort: { ts: -1 }, limit: 1 }).toArray())[0];
}

async findLastFailedAttemptByUsername(username: string): Promise<IServerEvent> {
return (await this.col.find({
MarcosSpessatto marked this conversation as resolved.
Show resolved Hide resolved
'u.username': username,
t: IServerEventType.FAILED_LOGIN_ATTEMPT,
}, { sort: { ts: -1 }, limit: 1 }).toArray())[0];
}

async countFailedAttemptsByUsernameSince(username: string, since: Date): Promise<number> {
return this.col.find({
'u.username': username,
rodrigok marked this conversation as resolved.
Show resolved Hide resolved
t: IServerEventType.FAILED_LOGIN_ATTEMPT,
ts: {
$gte: since,
},
}).count();
}

countFailedAttemptsByIpSince(ip: string, since: Date): Promise<number> {
return this.col.find({
ip,
rodrigok marked this conversation as resolved.
Show resolved Hide resolved
t: IServerEventType.FAILED_LOGIN_ATTEMPT,
ts: {
$gte: since,
},
}).count();
}

countFailedAttemptsByIp(ip: string): Promise<number> {
return this.col.find({
ip,
t: IServerEventType.FAILED_LOGIN_ATTEMPT,
}).count();
}

countFailedAttemptsByUsername(username: string): Promise<number> {
return this.col.find({
'u.username': username,
t: IServerEventType.FAILED_LOGIN_ATTEMPT,
}).count();
}
}
9 changes: 9 additions & 0 deletions app/models/server/raw/Sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,15 @@ export class SessionsRaw extends BaseRaw {
]).toArray();
}

async findLastLoginByIp(ip) {
MarcosSpessatto marked this conversation as resolved.
Show resolved Hide resolved
return (await this.col.find({
ip,
MarcosSpessatto marked this conversation as resolved.
Show resolved Hide resolved
}, {
sort: { loginAt: -1 },
limit: 1,
}).toArray())[0];
}

getActiveUsersOfPeriodByDayBetweenDates({ start, end }) {
return this.col.aggregate([
{
Expand Down
6 changes: 6 additions & 0 deletions app/models/server/raw/Users.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export class UsersRaw extends BaseRaw {
return this.find(query, options);
}

findOneByUsername(username, options) {
const query = { username };

return this.findOne(query, options);
}

findUsersInRolesWithQuery(roles, query, options) {
roles = [].concat(roles);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ import StatisticsModel from '../models/Statistics';
import { StatisticsRaw } from './Statistics';
import NotificationQueueModel from '../models/NotificationQueue';
import { NotificationQueueRaw } from './NotificationQueue';
import ServerEventModel from '../models/ServerEvents';
import { ServerEventsRaw } from './ServerEvents';
import { IServerEventsRepository } from '../definition/IServerEventsRepository';

export const Permissions = new PermissionsRaw(PermissionsModel.model.rawCollection());
export const Roles = new RolesRaw(RolesModel.model.rawCollection());
Expand All @@ -74,3 +77,4 @@ export const CustomUserStatus = new CustomUserStatusRaw(CustomUserStatusModel.mo
export const LivechatAgentActivity = new LivechatAgentActivityRaw(LivechatAgentActivityModel.model.rawCollection());
export const Statistics = new StatisticsRaw(StatisticsModel.model.rawCollection());
export const NotificationQueue = new NotificationQueueRaw(NotificationQueueModel.model.rawCollection());
export const ServerEvents: IServerEventsRepository = new ServerEventsRaw(ServerEventModel.model.rawCollection());
rodrigok marked this conversation as resolved.
Show resolved Hide resolved
4 changes: 4 additions & 0 deletions app/ui-login/client/login/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ Template.loginForm.events({
toastr.error(t('Wait_activation_warning'));
} else if (error.error === 'error-app-user-is-not-allowed-to-login') {
toastr.error(t('App_user_not_allowed_to_login'));
} else if (error.error === 'error-login-blocked-by-ip') {
toastr.error(t('Error_login_blocked_by_ip'));
} else if (error.error === 'error-login-blocked-by-user') {
toastr.error(t('Error_login_blocked_by_user'));
rodrigok marked this conversation as resolved.
Show resolved Hide resolved
} else {
return toastr.error(t('User_not_found_or_incorrect_password'));
}
Expand Down
1 change: 1 addition & 0 deletions app/utils/lib/date.helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const addMinutesToADate = (date: Date, minutes: number): Date => new Date(date.setMinutes(date.getMinutes() + minutes));
MarcosSpessatto marked this conversation as resolved.
Show resolved Hide resolved
Loading