From 12e7a6d071ba69bc203975f1f0ea1f2cc19b0e9b Mon Sep 17 00:00:00 2001 From: Marcos Defendi Date: Fri, 29 May 2020 13:28:10 -0300 Subject: [PATCH 1/8] Add ability to block failed login attempts by user and IP --- app/authentication/server/ILoginAttempt.ts | 20 +++++ app/authentication/server/index.ts | 1 + .../server/lib/restrictLoginAttempts.ts | 79 +++++++++++++++++++ .../authentication/server/startup/index.ts | 38 +++++++-- app/authentication/server/startup/settings.ts | 17 ++++ .../definition/IServerEventsRepository.ts | 11 +++ app/models/server/models/ServerEvents.ts | 10 +++ app/models/server/raw/ServerEvents.ts | 68 ++++++++++++++++ app/models/server/raw/Sessions.js | 9 +++ app/models/server/raw/Users.js | 6 ++ app/models/server/raw/{index.js => index.ts} | 4 + app/ui-login/client/login/form.js | 4 + app/utils/lib/date.helper.ts | 1 + definition/IServerEvent.ts | 14 ++++ packages/rocketchat-i18n/i18n/en.i18n.json | 11 +++ packages/rocketchat-i18n/i18n/pt-BR.i18n.json | 13 ++- server/importPackages.js | 1 + server/main.js | 1 - server/startup/migrations/v152.js | 2 +- 19 files changed, 299 insertions(+), 11 deletions(-) create mode 100644 app/authentication/server/ILoginAttempt.ts create mode 100644 app/authentication/server/index.ts create mode 100644 app/authentication/server/lib/restrictLoginAttempts.ts rename server/lib/accounts.js => app/authentication/server/startup/index.ts (88%) create mode 100644 app/authentication/server/startup/settings.ts create mode 100644 app/models/server/definition/IServerEventsRepository.ts create mode 100644 app/models/server/models/ServerEvents.ts create mode 100644 app/models/server/raw/ServerEvents.ts rename app/models/server/raw/{index.js => index.ts} (94%) create mode 100644 app/utils/lib/date.helper.ts create mode 100644 definition/IServerEvent.ts diff --git a/app/authentication/server/ILoginAttempt.ts b/app/authentication/server/ILoginAttempt.ts new file mode 100644 index 000000000000..7f0351010c0d --- /dev/null +++ b/app/authentication/server/ILoginAttempt.ts @@ -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; +} diff --git a/app/authentication/server/index.ts b/app/authentication/server/index.ts new file mode 100644 index 000000000000..0a9c130b5d84 --- /dev/null +++ b/app/authentication/server/index.ts @@ -0,0 +1 @@ +export * from './startup'; diff --git a/app/authentication/server/lib/restrictLoginAttempts.ts b/app/authentication/server/lib/restrictLoginAttempts.ts new file mode 100644 index 000000000000..ab187772634d --- /dev/null +++ b/app/authentication/server/lib/restrictLoginAttempts.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + await ServerEvents.insertOne({ + ip: login.connection.clientAddress, + t: IServerEventType.LOGIN, + ts: new Date(), + u: login.user, + }); +}; diff --git a/server/lib/accounts.js b/app/authentication/server/startup/index.ts similarity index 88% rename from server/lib/accounts.js rename to app/authentication/server/startup/index.ts index 951e360254aa..c0a1b2aac534 100644 --- a/server/lib/accounts.js +++ b/app/authentication/server/startup/index.ts @@ -5,13 +5,21 @@ 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'; Accounts.config({ forbidClientAccountCreation: true, @@ -84,6 +92,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 }); }; @@ -288,8 +297,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; @@ -385,3 +404,6 @@ Accounts.onLogin(async ({ user }) => { Users.removeOlderResumeTokensByUserId(user._id, oldestDate.when); } }); + +Accounts.onLoginFailure((login: ILoginAttempt) => saveFailedLoginAttempts(login)); +callbacks.add('afterValidateLogin', (login: ILoginAttempt) => saveSuccessfulLogin(login)); diff --git a/app/authentication/server/startup/settings.ts b/app/authentication/server/startup/settings.ts new file mode 100644 index 000000000000..cda18df51a7b --- /dev/null +++ b/app/authentication/server/startup/settings.ts @@ -0,0 +1,17 @@ +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' }); + 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' }); + }); + }); +}); diff --git a/app/models/server/definition/IServerEventsRepository.ts b/app/models/server/definition/IServerEventsRepository.ts new file mode 100644 index 000000000000..a7085d0e03d5 --- /dev/null +++ b/app/models/server/definition/IServerEventsRepository.ts @@ -0,0 +1,11 @@ +import { IServerEvent } from '../../../../definition/IServerEvent'; + +export interface IServerEventsRepository { + insertOne(data: Omit): Promise; + countFailedAttemptsByUsername(username: string): Promise; + countFailedAttemptsByUsernameSince(username: string, since: Date): Promise; + countFailedAttemptsByIpSince(ip: string, since: Date): Promise; + countFailedAttemptsByIp(ip: string): Promise; + findLastFailedAttemptByUsername(username: string): Promise; + findLastFailedAttemptByIp(ip: string): Promise; +} diff --git a/app/models/server/models/ServerEvents.ts b/app/models/server/models/ServerEvents.ts new file mode 100644 index 000000000000..185760189654 --- /dev/null +++ b/app/models/server/models/ServerEvents.ts @@ -0,0 +1,10 @@ +import { Base } from './_Base'; + +export class ServerEvents extends Base { + constructor() { + super('server_events'); + this.tryEnsureIndex({ ip: 1, ts: 1 }); + } +} + +export default new ServerEvents(); diff --git a/app/models/server/raw/ServerEvents.ts b/app/models/server/raw/ServerEvents.ts new file mode 100644 index 000000000000..5b6630d817ee --- /dev/null +++ b/app/models/server/raw/ServerEvents.ts @@ -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; + + async insertOne(data: Omit): Promise { + 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 { + return (await this.col.find({ + ip, + t: IServerEventType.FAILED_LOGIN_ATTEMPT, + }, { sort: { ts: -1 }, limit: 1 }).toArray())[0]; + } + + async findLastFailedAttemptByUsername(username: string): Promise { + return (await this.col.find({ + 'u.username': username, + t: IServerEventType.FAILED_LOGIN_ATTEMPT, + }, { sort: { ts: -1 }, limit: 1 }).toArray())[0]; + } + + async countFailedAttemptsByUsernameSince(username: string, since: Date): Promise { + return this.col.find({ + 'u.username': username, + t: IServerEventType.FAILED_LOGIN_ATTEMPT, + ts: { + $gte: since, + }, + }).count(); + } + + countFailedAttemptsByIpSince(ip: string, since: Date): Promise { + return this.col.find({ + ip, + t: IServerEventType.FAILED_LOGIN_ATTEMPT, + ts: { + $gte: since, + }, + }).count(); + } + + countFailedAttemptsByIp(ip: string): Promise { + return this.col.find({ + ip, + t: IServerEventType.FAILED_LOGIN_ATTEMPT, + }).count(); + } + + countFailedAttemptsByUsername(username: string): Promise { + return this.col.find({ + 'u.username': username, + t: IServerEventType.FAILED_LOGIN_ATTEMPT, + }).count(); + } +} diff --git a/app/models/server/raw/Sessions.js b/app/models/server/raw/Sessions.js index 4b259c10d466..a08e3a33d357 100644 --- a/app/models/server/raw/Sessions.js +++ b/app/models/server/raw/Sessions.js @@ -112,6 +112,15 @@ export class SessionsRaw extends BaseRaw { ]).toArray(); } + async findLastLoginByIp(ip) { + return (await this.col.find({ + ip, + }, { + sort: { loginAt: -1 }, + limit: 1, + }).toArray())[0]; + } + getActiveUsersOfPeriodByDayBetweenDates({ start, end }) { return this.col.aggregate([ { diff --git a/app/models/server/raw/Users.js b/app/models/server/raw/Users.js index 245ba2b579df..f1ef5a19e56d 100644 --- a/app/models/server/raw/Users.js +++ b/app/models/server/raw/Users.js @@ -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); diff --git a/app/models/server/raw/index.js b/app/models/server/raw/index.ts similarity index 94% rename from app/models/server/raw/index.js rename to app/models/server/raw/index.ts index ed74d56e7ec0..5bcb70d06ac0 100644 --- a/app/models/server/raw/index.js +++ b/app/models/server/raw/index.ts @@ -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()); @@ -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()); diff --git a/app/ui-login/client/login/form.js b/app/ui-login/client/login/form.js index 7440f56dd894..10e3e0457dd4 100644 --- a/app/ui-login/client/login/form.js +++ b/app/ui-login/client/login/form.js @@ -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')); } else { return toastr.error(t('User_not_found_or_incorrect_password')); } diff --git a/app/utils/lib/date.helper.ts b/app/utils/lib/date.helper.ts new file mode 100644 index 000000000000..a88043a38f42 --- /dev/null +++ b/app/utils/lib/date.helper.ts @@ -0,0 +1 @@ +export const addMinutesToADate = (date: Date, minutes: number): Date => new Date(date.setMinutes(date.getMinutes() + minutes)); diff --git a/definition/IServerEvent.ts b/definition/IServerEvent.ts new file mode 100644 index 000000000000..d1492ad12228 --- /dev/null +++ b/definition/IServerEvent.ts @@ -0,0 +1,14 @@ +import { IUser } from './IUser'; + +export enum IServerEventType { + FAILED_LOGIN_ATTEMPT = 'failed-login-attempt', + LOGIN = 'login', +} + +export interface IServerEvent { + _id: string; + t: IServerEventType; + ts: Date; + ip?: string; + u?: IUser; +} diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 9eae064e5cf4..9fc429bab4a5 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -51,6 +51,14 @@ "Accounts_AvatarExternalProviderUrl_Description": "Example: `https://acme.com/api/v1/{username}`", "Accounts_BlockedDomainsList": "Blocked Domains List", "Accounts_BlockedDomainsList_Description": "Comma-separated list of blocked domains", + "Accounts_Block_Failed_Attempts_Ip_Whitelist": "IP Whitelist", + "Accounts_Block_Failed_Attempts_Ip_Whitelist_Description": "Comma-separated list of whitelisted IPs", + "Accounts_Block_Failed_Attempts_Time_To_Unblock_By_Ip_In_Minutes": "Time to unblock IP (In Minutes)", + "Accounts_Block_Failed_Attempts_Time_To_Unblock_By_User_In_Minutes": "Time to unblock User (In Minutes)", + "Accounts_Block_Failed_Attempts_Until_Block_By_Ip": "How many failed attempts until block by IP", + "Accounts_Block_Failed_Attempts_Until_Block_By_User": "How many failed attempts until block by User", + "Accounts_Block_Failed_Login_Attempts_By_Ip": "Block failed login attempts by IP", + "Accounts_Block_Failed_Login_Attempts_By_User": "Block failed login attempts by Username", "Accounts_BlockedUsernameList": "Blocked Username List", "Accounts_BlockedUsernameList_Description": "Comma-separated list of blocked usernames (case-insensitive)", "Accounts_CustomFields_Description": "Should be a valid JSON where keys are the field names containing a dictionary of field settings. Example:
{\n \"role\": {\n  \"type\": \"select\",\n  \"defaultValue\": \"student\",\n  \"options\": [\"teacher\", \"student\"],\n  \"required\": true,\n  \"modifyRecordField\": {\n   \"array\": true,\n   \"field\": \"roles\"\n  }\n },\n \"twitter\": {\n  \"type\": \"text\",\n  \"required\": true,\n  \"minLength\": 2,\n  \"maxLength\": 10\n }\n} ", @@ -1482,6 +1490,8 @@ "Error_404": "Error:404", "Error_changing_password": "Error changing password", "Error_loading_pages": "Error loading pages", + "Error_login_blocked_by_ip": "Login has been temporarily blocked for this IP", + "Error_login_blocked_by_user": "Login has been temporarily blocked for this User", "Error_RocketChat_requires_oplog_tailing_when_running_in_multiple_instances": "Error: Rocket.Chat requires oplog tailing when running in multiple instances", "Error_RocketChat_requires_oplog_tailing_when_running_in_multiple_instances_details": "Please make sure your MongoDB is on ReplicaSet mode and MONGO_OPLOG_URL environment variable is defined correctly on the application server", "Error_sending_livechat_transcript": "Error sending Omnichannel transcript", @@ -1520,6 +1530,7 @@ "External_Service": "External Service", "Facebook_Page": "Facebook Page", "Failed": "Failed", + "Failed_Login_Attempts": "Failed Login Attempts", "Failed_to_activate_invite_token": "Failed to activate invite token", "Failed_To_Download_Files": "Failed to download files", "Failed_to_generate_invite_link": "Failed to generate invite link", diff --git a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index df210f33fae6..5831c3adedee 100644 --- a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -51,6 +51,14 @@ "Accounts_AvatarExternalProviderUrl_Description": "Exemplo: `https://acme.com/api/v1/ {username}`", "Accounts_BlockedDomainsList": "Lista de Domínios Bloqueados", "Accounts_BlockedDomainsList_Description": "Lista de domínios bloqueados, separados por vírgulas ", + "Accounts_Block_Failed_Attempts_Ip_Whitelist": "Lista de IPs para não verificar", + "Accounts_Block_Failed_Attempts_Ip_Whitelist_Description": "Lista de IPs separados por vírgula", + "Accounts_Block_Failed_Attempts_Time_To_Unblock_By_User_In_Minutes": "Tempo para desbloquear o Usuário (Em Minutos)", + "Accounts_Block_Failed_Attempts_Time_To_Unblock_By_Ip_In_Minutes": "Tempo para desbloquear o IP (Em Minutos)", + "Accounts_Block_Failed_Attempts_Until_Block_By_Ip": "Quantas tentativas falhas até bloquear por IP", + "Accounts_Block_Failed_Attempts_Until_Block_By_User": "Quantas tentativas falhas até bloquear por Usuário", + "Accounts_Block_Failed_Login_Attempts_By_User": "Bloquear tentativas falhas de login por Usuário", + "Accounts_Block_Failed_Login_Attempts_By_Ip": "Bloquear tentativas falhas de login por IP", "Accounts_BlockedUsernameList": "Lista de Nomes de Usuário Bloqueados", "Accounts_BlockedUsernameList_Description": "Lista de nomes de usuários bloqueados, separada por vírgulas (não diferencia maiúsculas)", "Accounts_CustomFields_Description": "Deve ser um JSON válido onde as chaves são os nomes de campos contendo um dicionário de configuração de campos. Exemplo:
{\n \"role\": {\n  \"type\": \"select\",\n  \"defaultValue\": \"student\",\n  \"options\": [\"teacher\", \"student\"],\n  \"required\": true,\n  \"modifyRecordField\": {\n   \"array\": true,\n   \"field\": \"roles\"\n  }\n },\n \"twitter\": {\n  \"type\": \"text\",\n  \"required\": true,\n  \"minLength\": 2,\n  \"maxLength\": 10\n }\n} ", @@ -1362,6 +1370,8 @@ "Error_404": "Erro 404", "Error_changing_password": "Erro ao alterar senha", "Error_loading_pages": "Erro ao carregar páginas", + "Error_login_blocked_by_ip": "O login foi temporariamente bloqueado para este IP", + "Error_login_blocked_by_user": "O login foi temporariamente bloqueado para este Usuário", "Error_RocketChat_requires_oplog_tailing_when_running_in_multiple_instances": "Erro: Rocket.Chat requer oplog tailing quando executado em várias instâncias", "Error_RocketChat_requires_oplog_tailing_when_running_in_multiple_instances_details": "Certifique-se de que seu MongoDB esteja no modo ReplicaSet e a variável de ambiente MONGO_OPLOG_URL esteja definida corretamente no servidor de aplicativos", "Error_sending_livechat_transcript": "Erro ao enviar transcript do Omnichannel", @@ -1396,6 +1406,7 @@ "External_Queue_Service_URL": "URL do Serviço de Fila Externa", "External_Service": "Serviço Externo", "Facebook_Page": "Página do Facebook", + "Failed_Login_Attempts": "Tentativas falhas de Login", "Failed_to_activate_invite_token": "Falha na ativação do token de convite", "Failed_to_generate_invite_link": "Falha na geração do link de convite", "Failed_to_validate_invite_token": "Falha na validação do token de convite", @@ -3436,4 +3447,4 @@ "Your_question": "A sua pergunta", "Your_server_link": "O link do seu servidor", "Your_workspace_is_ready": "O seu espaço de trabalho está pronto a usar 🎉" -} \ No newline at end of file +} diff --git a/server/importPackages.js b/server/importPackages.js index aa56233f2bf5..124fb8818d6b 100644 --- a/server/importPackages.js +++ b/server/importPackages.js @@ -112,3 +112,4 @@ import '../app/ui-utils'; import '../app/action-links/server'; import '../app/reactions/server'; import '../app/livechat/server'; +import '../app/authentication/server'; diff --git a/server/main.js b/server/main.js index fd0439b8d890..5e9bca287c72 100644 --- a/server/main.js +++ b/server/main.js @@ -4,7 +4,6 @@ import '../imports/startup/server'; import '../lib/RegExp'; import '../ee/server'; -import './lib/accounts'; import './lib/pushConfig'; import './lib/roomFiles'; import './startup/migrations'; diff --git a/server/startup/migrations/v152.js b/server/startup/migrations/v152.js index abd786cc7093..15232c685a4c 100644 --- a/server/startup/migrations/v152.js +++ b/server/startup/migrations/v152.js @@ -2,7 +2,7 @@ import { Meteor } from 'meteor/meteor'; import { Migrations } from '../../../app/migrations/server'; import { Users } from '../../../app/models/server'; -import { MAX_RESUME_LOGIN_TOKENS } from '../../lib/accounts'; +import { MAX_RESUME_LOGIN_TOKENS } from '../../../app/authentication/server'; Migrations.add({ version: 152, From 434733e89f6587f24ec6f2ef9a1539426c63ee81 Mon Sep 17 00:00:00 2001 From: Marcos Defendi Date: Mon, 1 Jun 2020 15:14:15 -0300 Subject: [PATCH 2/8] Add log audit to failed login attempts --- .../server/lib/logLoginAttempts.ts | 27 +++++++++++++++++++ app/authentication/server/startup/index.ts | 7 ++++- app/authentication/server/startup/settings.ts | 18 +++++++++++++ packages/rocketchat-i18n/i18n/en.i18n.json | 5 ++++ packages/rocketchat-i18n/i18n/pt-BR.i18n.json | 5 ++++ 5 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 app/authentication/server/lib/logLoginAttempts.ts diff --git a/app/authentication/server/lib/logLoginAttempts.ts b/app/authentication/server/lib/logLoginAttempts.ts new file mode 100644 index 000000000000..128323062d71 --- /dev/null +++ b/app/authentication/server/lib/logLoginAttempts.ts @@ -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); +}; diff --git a/app/authentication/server/startup/index.ts b/app/authentication/server/startup/index.ts index c0a1b2aac534..130d6bdc6229 100644 --- a/app/authentication/server/startup/index.ts +++ b/app/authentication/server/startup/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ import { Meteor } from 'meteor/meteor'; import { Match } from 'meteor/check'; import { Accounts } from 'meteor/accounts-base'; @@ -20,6 +21,7 @@ import { } from '../lib/restrictLoginAttempts'; import { ILoginAttempt } from '../ILoginAttempt'; import './settings'; +import { logFailedLoginAttemps } from '../lib/logLoginAttempts'; Accounts.config({ forbidClientAccountCreation: true, @@ -405,5 +407,8 @@ Accounts.onLogin(async ({ user }) => { } }); -Accounts.onLoginFailure((login: ILoginAttempt) => saveFailedLoginAttempts(login)); +Accounts.onLoginFailure((login: ILoginAttempt) => { + saveFailedLoginAttempts(login); + logFailedLoginAttemps(login); +}); callbacks.add('afterValidateLogin', (login: ILoginAttempt) => saveSuccessfulLogin(login)); diff --git a/app/authentication/server/startup/settings.ts b/app/authentication/server/startup/settings.ts index cda18df51a7b..1380167fd8fc 100644 --- a/app/authentication/server/startup/settings.ts +++ b/app/authentication/server/startup/settings.ts @@ -12,6 +12,24 @@ Meteor.startup(function() { this.add('Accounts_Block_Failed_Attempts_Until_Block_By_Ip', 10, { type: 'int' }); 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 }, + }); }); }); }); diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 9fc429bab4a5..7d1e27d2542a 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -82,6 +82,11 @@ "Accounts_Enrollment_Email_Subject_Default": "Welcome to [Site_Name]", "Accounts_Enrollment_Email": "Enrollment Email", "Accounts_Enrollment_Email_Description": "You may use the following placeholders:
  • [name], [fname], [lname] for the user's full name, first name or last name, respectively.
  • [email] for the user's email.
  • [Site_Name] and [Site_URL] for the Application Name and URL respectively.
", + "Accounts_FailedLoginAudit_Enabled": "Log (on console) failed login attempts", + "Accounts_FailedLoginAudit_Log_ClientIp": "Show Client IP on failed login attempts logs", + "Accounts_FailedLoginAudit_Log_ForwardedForIp": "Show Forwarded IP on failed login attempts logs", + "Accounts_FailedLoginAudit_Log_Username": "Show Username on failed login attempts logs", + "Accounts_FailedLoginAudit_Log_UserAgent": "Show UserAgent on failed login attempts logs", "Accounts_ForgetUserSessionOnWindowClose": "Forget User Session on Window Close", "Accounts_Iframe_api_method": "Api Method", "Accounts_Iframe_api_url": "API URL", diff --git a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index 5831c3adedee..9fbd323bcf28 100644 --- a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -82,6 +82,11 @@ "Accounts_Enrollment_Email_Subject_Default": "Bem-vindo ao [Site_Name]", "Accounts_Enrollment_Email": "E-mail de Inscrição", "Accounts_Enrollment_Email_Description": "Você pode usar os seguintes placeholders:
  • [name], [fname], [lname] para o nome completo do usuário, primeiro nome ou sobrenome, respectivamente.
  • [email] para o e-mail do usuário.
  • [Site_Name] e [Site_URL] para o Nome da Aplicação e URL, respectivamente.
", + "Accounts_FailedLoginAudit_Enabled": "Registrar(no console) tentativas falhas de login", + "Accounts_FailedLoginAudit_Log_ClientIp": "Mostrar o IP do cliente nos registros de tentativas falhas de login", + "Accounts_FailedLoginAudit_Log_ForwardedForIp": "Mostrar o 'ForwardedFor' IP nos logs de tentativas falhas de login", + "Accounts_FailedLoginAudit_Log_Username": "Mostrar o Nome do usuário nos logs de tentativas falhas de login", + "Accounts_FailedLoginAudit_Log_UserAgent": "Mostrar o 'UserAgent' nos logs de tentativas falhas de login", "Accounts_ForgetUserSessionOnWindowClose": "Esqueça a sessão do usuário ao fechar a janela", "Accounts_Iframe_api_method": "Método HTTP da API", "Accounts_Iframe_api_url": "URL da API", From ed3713a834c5ee90e12be0f32fc7085695551457 Mon Sep 17 00:00:00 2001 From: Marcos Defendi Date: Mon, 15 Jun 2020 21:30:33 -0300 Subject: [PATCH 3/8] Apply suggestions from review --- app/authentication/server/startup/index.ts | 4 ++-- app/authentication/server/startup/settings.ts | 2 +- .../server/definition/IServerEventsRepository.ts | 11 ----------- app/models/server/models/ServerEvents.ts | 3 ++- app/models/server/models/Sessions.js | 1 + app/models/server/raw/ServerEvents.ts | 3 +-- app/models/server/raw/index.ts | 3 +-- app/ui-login/client/login/form.js | 8 ++++---- app/utils/lib/date.helper.ts | 5 ++++- definition/IServerEvent.ts | 4 ++-- packages/rocketchat-i18n/i18n/en.i18n.json | 4 ++-- packages/rocketchat-i18n/i18n/pt-BR.i18n.json | 4 ++-- 12 files changed, 22 insertions(+), 30 deletions(-) delete mode 100644 app/models/server/definition/IServerEventsRepository.ts diff --git a/app/authentication/server/startup/index.ts b/app/authentication/server/startup/index.ts index 130d6bdc6229..459e40f13559 100644 --- a/app/authentication/server/startup/index.ts +++ b/app/authentication/server/startup/index.ts @@ -302,12 +302,12 @@ Accounts.insertUserDoc = _.wrap(Accounts.insertUserDoc, function(insertUserDoc, 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', { + throw new Meteor.Error('error-login-blocked-for-ip', 'Login has been temporarily blocked For 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', { + throw new Meteor.Error('error-login-blocked-for-user', 'Login has been temporarily blocked For User', { function: 'Accounts.validateLoginAttempt', }); } diff --git a/app/authentication/server/startup/settings.ts b/app/authentication/server/startup/settings.ts index 1380167fd8fc..427969cb36e9 100644 --- a/app/authentication/server/startup/settings.ts +++ b/app/authentication/server/startup/settings.ts @@ -9,7 +9,7 @@ Meteor.startup(function() { 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' }); + this.add('Accounts_Block_Failed_Attempts_Until_Block_By_Ip', 50, { type: 'int' }); 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' }); diff --git a/app/models/server/definition/IServerEventsRepository.ts b/app/models/server/definition/IServerEventsRepository.ts deleted file mode 100644 index a7085d0e03d5..000000000000 --- a/app/models/server/definition/IServerEventsRepository.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IServerEvent } from '../../../../definition/IServerEvent'; - -export interface IServerEventsRepository { - insertOne(data: Omit): Promise; - countFailedAttemptsByUsername(username: string): Promise; - countFailedAttemptsByUsernameSince(username: string, since: Date): Promise; - countFailedAttemptsByIpSince(ip: string, since: Date): Promise; - countFailedAttemptsByIp(ip: string): Promise; - findLastFailedAttemptByUsername(username: string): Promise; - findLastFailedAttemptByIp(ip: string): Promise; -} diff --git a/app/models/server/models/ServerEvents.ts b/app/models/server/models/ServerEvents.ts index 185760189654..09b17ac51067 100644 --- a/app/models/server/models/ServerEvents.ts +++ b/app/models/server/models/ServerEvents.ts @@ -3,7 +3,8 @@ import { Base } from './_Base'; export class ServerEvents extends Base { constructor() { super('server_events'); - this.tryEnsureIndex({ ip: 1, ts: 1 }); + this.tryEnsureIndex({ t: 1, ip: 1, ts: -1 }); + this.tryEnsureIndex({ t: 1, 'u.username': 1, ts: -1 }); } } diff --git a/app/models/server/models/Sessions.js b/app/models/server/models/Sessions.js index 5992261e2f94..a1da719437ad 100644 --- a/app/models/server/models/Sessions.js +++ b/app/models/server/models/Sessions.js @@ -378,6 +378,7 @@ export class Sessions extends Base { this.tryEnsureIndex({ instanceId: 1, sessionId: 1 }); this.tryEnsureIndex({ year: 1, month: 1, day: 1, type: 1 }); this.tryEnsureIndex({ type: 1 }); + this.tryEnsureIndex({ ip: 1, loginAt: 1 }); this.tryEnsureIndex({ _computedAt: 1 }, { expireAfterSeconds: 60 * 60 * 24 * 45 }); } diff --git a/app/models/server/raw/ServerEvents.ts b/app/models/server/raw/ServerEvents.ts index 5b6630d817ee..7d8765e08934 100644 --- a/app/models/server/raw/ServerEvents.ts +++ b/app/models/server/raw/ServerEvents.ts @@ -2,10 +2,9 @@ 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 { +export class ServerEventsRaw extends BaseRaw { public readonly col!: Collection; async insertOne(data: Omit): Promise { diff --git a/app/models/server/raw/index.ts b/app/models/server/raw/index.ts index 5bcb70d06ac0..59df394cd70e 100644 --- a/app/models/server/raw/index.ts +++ b/app/models/server/raw/index.ts @@ -50,7 +50,6 @@ 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()); @@ -77,4 +76,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()); +export const ServerEvents = new ServerEventsRaw(ServerEventModel.model.rawCollection()); diff --git a/app/ui-login/client/login/form.js b/app/ui-login/client/login/form.js index 10e3e0457dd4..198d6c49d355 100644 --- a/app/ui-login/client/login/form.js +++ b/app/ui-login/client/login/form.js @@ -149,10 +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')); + } else if (error.error === 'error-login-blocked-for-ip') { + toastr.error(t('Error_login_blocked_for_ip')); + } else if (error.error === 'error-login-blocked-for-user') { + toastr.error(t('Error_login_blocked_for_user')); } else { return toastr.error(t('User_not_found_or_incorrect_password')); } diff --git a/app/utils/lib/date.helper.ts b/app/utils/lib/date.helper.ts index a88043a38f42..6ebce585173b 100644 --- a/app/utils/lib/date.helper.ts +++ b/app/utils/lib/date.helper.ts @@ -1 +1,4 @@ -export const addMinutesToADate = (date: Date, minutes: number): Date => new Date(date.setMinutes(date.getMinutes() + minutes)); +export const addMinutesToADate = (date: Date, minutes: number): Date => { + const copy = new Date(date); + return new Date(copy.setMinutes(copy.getMinutes() + minutes)); +}; diff --git a/definition/IServerEvent.ts b/definition/IServerEvent.ts index d1492ad12228..aebebff1c603 100644 --- a/definition/IServerEvent.ts +++ b/definition/IServerEvent.ts @@ -9,6 +9,6 @@ export interface IServerEvent { _id: string; t: IServerEventType; ts: Date; - ip?: string; - u?: IUser; + ip: string; + u: IUser; } diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 7d1e27d2542a..ef05939b1783 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -1495,8 +1495,8 @@ "Error_404": "Error:404", "Error_changing_password": "Error changing password", "Error_loading_pages": "Error loading pages", - "Error_login_blocked_by_ip": "Login has been temporarily blocked for this IP", - "Error_login_blocked_by_user": "Login has been temporarily blocked for this User", + "Error_login_blocked_for_ip": "Login has been temporarily blocked for this IP", + "Error_login_blocked_for_user": "Login has been temporarily blocked for this User", "Error_RocketChat_requires_oplog_tailing_when_running_in_multiple_instances": "Error: Rocket.Chat requires oplog tailing when running in multiple instances", "Error_RocketChat_requires_oplog_tailing_when_running_in_multiple_instances_details": "Please make sure your MongoDB is on ReplicaSet mode and MONGO_OPLOG_URL environment variable is defined correctly on the application server", "Error_sending_livechat_transcript": "Error sending Omnichannel transcript", diff --git a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index 9fbd323bcf28..39ba604359cc 100644 --- a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -1375,8 +1375,8 @@ "Error_404": "Erro 404", "Error_changing_password": "Erro ao alterar senha", "Error_loading_pages": "Erro ao carregar páginas", - "Error_login_blocked_by_ip": "O login foi temporariamente bloqueado para este IP", - "Error_login_blocked_by_user": "O login foi temporariamente bloqueado para este Usuário", + "Error_login_blocked_for_ip": "O login foi temporariamente bloqueado para este IP", + "Error_login_blocked_for_user": "O login foi temporariamente bloqueado para este Usuário", "Error_RocketChat_requires_oplog_tailing_when_running_in_multiple_instances": "Erro: Rocket.Chat requer oplog tailing quando executado em várias instâncias", "Error_RocketChat_requires_oplog_tailing_when_running_in_multiple_instances_details": "Certifique-se de que seu MongoDB esteja no modo ReplicaSet e a variável de ambiente MONGO_OPLOG_URL esteja definida corretamente no servidor de aplicativos", "Error_sending_livechat_transcript": "Erro ao enviar transcript do Omnichannel", From 34cca5e2bd1156ae71b80c009d4f0d06a9ccf81a Mon Sep 17 00:00:00 2001 From: Marcos Defendi Date: Tue, 16 Jun 2020 15:55:13 -0300 Subject: [PATCH 4/8] Apply suggestions from review --- app/models/server/raw/ServerEvents.ts | 8 ++++---- app/utils/lib/date.helper.ts | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/models/server/raw/ServerEvents.ts b/app/models/server/raw/ServerEvents.ts index 7d8765e08934..2ef7dec19825 100644 --- a/app/models/server/raw/ServerEvents.ts +++ b/app/models/server/raw/ServerEvents.ts @@ -18,17 +18,17 @@ export class ServerEventsRaw extends BaseRaw { } async findLastFailedAttemptByIp(ip: string): Promise { - return (await this.col.find({ + return this.findOne({ ip, t: IServerEventType.FAILED_LOGIN_ATTEMPT, - }, { sort: { ts: -1 }, limit: 1 }).toArray())[0]; + }); } async findLastFailedAttemptByUsername(username: string): Promise { - return (await this.col.find({ + return this.findOne({ 'u.username': username, t: IServerEventType.FAILED_LOGIN_ATTEMPT, - }, { sort: { ts: -1 }, limit: 1 }).toArray())[0]; + }); } async countFailedAttemptsByUsernameSince(username: string, since: Date): Promise { diff --git a/app/utils/lib/date.helper.ts b/app/utils/lib/date.helper.ts index 6ebce585173b..6277b84ddca5 100644 --- a/app/utils/lib/date.helper.ts +++ b/app/utils/lib/date.helper.ts @@ -1,4 +1,5 @@ export const addMinutesToADate = (date: Date, minutes: number): Date => { const copy = new Date(date); - return new Date(copy.setMinutes(copy.getMinutes() + minutes)); + copy.setMinutes(copy.getMinutes() + minutes); + return copy; }; From 5912b847805587fa3cbbb6372a94ebcd86cea133 Mon Sep 17 00:00:00 2001 From: Marcos Defendi Date: Tue, 16 Jun 2020 18:30:45 -0300 Subject: [PATCH 5/8] Add a setting to enable globally login data collection --- app/authentication/server/hooks/login.ts | 21 ++++++++++ app/authentication/server/index.ts | 2 + .../server/lib/restrictLoginAttempts.ts | 17 +++++++-- .../server/startup/{index.ts => index.js} | 12 +----- app/authentication/server/startup/settings.ts | 38 +++++++++++++++---- app/models/server/raw/ServerEvents.ts | 12 +++--- packages/rocketchat-i18n/i18n/en.i18n.json | 1 + packages/rocketchat-i18n/i18n/pt-BR.i18n.json | 1 + 8 files changed, 76 insertions(+), 28 deletions(-) create mode 100644 app/authentication/server/hooks/login.ts rename app/authentication/server/startup/{index.ts => index.js} (96%) diff --git a/app/authentication/server/hooks/login.ts b/app/authentication/server/hooks/login.ts new file mode 100644 index 000000000000..4df5b04f8e9d --- /dev/null +++ b/app/authentication/server/hooks/login.ts @@ -0,0 +1,21 @@ +import { Accounts } from 'meteor/accounts-base'; + +import { ILoginAttempt } from '../ILoginAttempt'; +import { saveFailedLoginAttempts, saveSuccessfulLogin } from '../lib/restrictLoginAttempts'; +import { logFailedLoginAttemps } from '../lib/logLoginAttempts'; +import { callbacks } from '../../../callbacks/server'; +import { settings } from '../../../settings/server'; + +Accounts.onLoginFailure((login: ILoginAttempt) => { + if (!settings.get('Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data')) { + return; + } + saveFailedLoginAttempts(login); + logFailedLoginAttemps(login); +}); +callbacks.add('afterValidateLogin', (login: ILoginAttempt) => { + if (!settings.get('Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data')) { + return; + } + saveSuccessfulLogin(login); +}); diff --git a/app/authentication/server/index.ts b/app/authentication/server/index.ts index 0a9c130b5d84..6bca5708d5d2 100644 --- a/app/authentication/server/index.ts +++ b/app/authentication/server/index.ts @@ -1 +1,3 @@ +import './hooks/login'; + export * from './startup'; diff --git a/app/authentication/server/lib/restrictLoginAttempts.ts b/app/authentication/server/lib/restrictLoginAttempts.ts index ab187772634d..9932b1a6f77a 100644 --- a/app/authentication/server/lib/restrictLoginAttempts.ts +++ b/app/authentication/server/lib/restrictLoginAttempts.ts @@ -10,7 +10,9 @@ import Sessions from '../../../models/server/raw/Sessions'; export const isValidLoginAttemptByIp = async (ip: string): Promise => { 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)) { + if (!settings.get('Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data') + || !settings.get('Accounts_Block_Failed_Login_Attempts_By_Ip') + || whitelist.includes(ip)) { return true; } const lastLogin = await Sessions.findLastLoginByIp(ip); @@ -24,14 +26,18 @@ export const isValidLoginAttemptByIp = async (ip: string): Promise => { if (attemptsUntilBlock && failedAttemptsSinceLastLogin < attemptsUntilBlock) { return true; } - const lastAttemptAt = (await ServerEvents.findLastFailedAttemptByIp(ip)).ts; + const lastAttemptAt = (await ServerEvents.findLastFailedAttemptByIp(ip))?.ts; + if (!lastAttemptAt) { + return true; + } 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 => { - if (!settings.get('Accounts_Block_Failed_Login_Attempts_By_User')) { + if (!settings.get('Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data') + || !settings.get('Accounts_Block_Failed_Login_Attempts_By_User')) { return true; } const user = await Users.findOneByUsername(login.methodArguments[0].user?.username) || login.user; @@ -48,7 +54,10 @@ export const isValidAttemptByUser = async (login: ILoginAttempt): Promise { Users.removeOlderResumeTokensByUserId(user._id, oldestDate.when); } }); - -Accounts.onLoginFailure((login: ILoginAttempt) => { - saveFailedLoginAttempts(login); - logFailedLoginAttemps(login); -}); -callbacks.add('afterValidateLogin', (login: ILoginAttempt) => saveSuccessfulLogin(login)); diff --git a/app/authentication/server/startup/settings.ts b/app/authentication/server/startup/settings.ts index 427969cb36e9..1a485cae7383 100644 --- a/app/authentication/server/startup/settings.ts +++ b/app/authentication/server/startup/settings.ts @@ -5,13 +5,37 @@ 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', 50, { type: 'int' }); - 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_Block_Failed_Login_Attempts_Enable_Collect_Login_data', true, { + type: 'boolean', + }); + this.add('Accounts_Block_Failed_Login_Attempts_By_User', true, { + type: 'boolean', + enableQuery: { _id: 'Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data', value: true }, + }); + this.add('Accounts_Block_Failed_Attempts_Until_Block_By_User', 10, { + type: 'int', + enableQuery: { _id: 'Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data', value: true }, + }); + this.add('Accounts_Block_Failed_Attempts_Time_To_Unblock_By_User_In_Minutes', 5, { + type: 'int', + enableQuery: { _id: 'Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data', value: true }, + }); + this.add('Accounts_Block_Failed_Login_Attempts_By_Ip', true, { + type: 'boolean', + enableQuery: { _id: 'Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data', value: true }, + }); + this.add('Accounts_Block_Failed_Attempts_Until_Block_By_Ip', 50, { + type: 'int', + enableQuery: { _id: 'Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data', value: true }, + }); + this.add('Accounts_Block_Failed_Attempts_Time_To_Unblock_By_Ip_In_Minutes', 5, { + type: 'int', + enableQuery: { _id: 'Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data', value: true }, + }); + this.add('Accounts_Block_Failed_Attempts_Ip_Whitelist', '', { + type: 'string', + enableQuery: { _id: 'Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data', value: true }, + }); this.add('Accounts_FailedLoginAudit_Enabled', false, { type: 'boolean' }); this.add('Accounts_FailedLoginAudit_Log_Username', false, { diff --git a/app/models/server/raw/ServerEvents.ts b/app/models/server/raw/ServerEvents.ts index 2ef7dec19825..772ee8971664 100644 --- a/app/models/server/raw/ServerEvents.ts +++ b/app/models/server/raw/ServerEvents.ts @@ -17,18 +17,18 @@ export class ServerEventsRaw extends BaseRaw { }); } - async findLastFailedAttemptByIp(ip: string): Promise { - return this.findOne({ + async findLastFailedAttemptByIp(ip: string): Promise { + return this.col.findOne({ ip, t: IServerEventType.FAILED_LOGIN_ATTEMPT, - }); + }, { sort: { ts: -1 } }); } - async findLastFailedAttemptByUsername(username: string): Promise { - return this.findOne({ + async findLastFailedAttemptByUsername(username: string): Promise { + return this.col.findOne({ 'u.username': username, t: IServerEventType.FAILED_LOGIN_ATTEMPT, - }); + }, { sort: { ts: -1 } }); } async countFailedAttemptsByUsernameSince(username: string, since: Date): Promise { diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 849f123bd236..27cbdcf21703 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -56,6 +56,7 @@ "Accounts_AvatarExternalProviderUrl_Description": "Example: `https://acme.com/api/v1/{username}`", "Accounts_BlockedDomainsList": "Blocked Domains List", "Accounts_BlockedDomainsList_Description": "Comma-separated list of blocked domains", + "Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data": "Enable collect log in data", "Accounts_Block_Failed_Attempts_Ip_Whitelist": "IP Whitelist", "Accounts_Block_Failed_Attempts_Ip_Whitelist_Description": "Comma-separated list of whitelisted IPs", "Accounts_Block_Failed_Attempts_Time_To_Unblock_By_Ip_In_Minutes": "Time to unblock IP (In Minutes)", diff --git a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index 39ba604359cc..6517559f7b5e 100644 --- a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -51,6 +51,7 @@ "Accounts_AvatarExternalProviderUrl_Description": "Exemplo: `https://acme.com/api/v1/ {username}`", "Accounts_BlockedDomainsList": "Lista de Domínios Bloqueados", "Accounts_BlockedDomainsList_Description": "Lista de domínios bloqueados, separados por vírgulas ", + "Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data": "Habilitar a coleta de dados do login", "Accounts_Block_Failed_Attempts_Ip_Whitelist": "Lista de IPs para não verificar", "Accounts_Block_Failed_Attempts_Ip_Whitelist_Description": "Lista de IPs separados por vírgula", "Accounts_Block_Failed_Attempts_Time_To_Unblock_By_User_In_Minutes": "Tempo para desbloquear o Usuário (Em Minutos)", From a76847c9a479b7852ebb885c87676acf2b154cb7 Mon Sep 17 00:00:00 2001 From: Marcos Defendi Date: Wed, 17 Jun 2020 13:33:07 -0300 Subject: [PATCH 6/8] Apply suggestions --- app/authentication/server/startup/index.js | 2 -- app/authentication/server/startup/settings.ts | 24 ++++++++++--------- packages/rocketchat-i18n/i18n/en.i18n.json | 1 + packages/rocketchat-i18n/i18n/pt-BR.i18n.json | 1 + 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/app/authentication/server/startup/index.js b/app/authentication/server/startup/index.js index 5882779a7e95..3f4d0a67cb6c 100644 --- a/app/authentication/server/startup/index.js +++ b/app/authentication/server/startup/index.js @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ import { Meteor } from 'meteor/meteor'; import { Match } from 'meteor/check'; import { Accounts } from 'meteor/accounts-base'; @@ -90,7 +89,6 @@ 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 }); }; diff --git a/app/authentication/server/startup/settings.ts b/app/authentication/server/startup/settings.ts index 1a485cae7383..d3e0e9595707 100644 --- a/app/authentication/server/startup/settings.ts +++ b/app/authentication/server/startup/settings.ts @@ -4,55 +4,57 @@ import { settings } from '../../../settings/server'; Meteor.startup(function() { settings.addGroup('Accounts', function() { + const enableQueryCollectData = { _id: 'Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data', value: true }; + const enableQueryAudit = { _id: 'Accounts_FailedLoginAudit_Enabled', value: true }; this.section('Failed_Login_Attempts', function() { this.add('Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data', true, { type: 'boolean', }); this.add('Accounts_Block_Failed_Login_Attempts_By_User', true, { type: 'boolean', - enableQuery: { _id: 'Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data', value: true }, + enableQuery: enableQueryCollectData, }); this.add('Accounts_Block_Failed_Attempts_Until_Block_By_User', 10, { type: 'int', - enableQuery: { _id: 'Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data', value: true }, + enableQuery: enableQueryCollectData, }); this.add('Accounts_Block_Failed_Attempts_Time_To_Unblock_By_User_In_Minutes', 5, { type: 'int', - enableQuery: { _id: 'Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data', value: true }, + enableQuery: enableQueryCollectData, }); this.add('Accounts_Block_Failed_Login_Attempts_By_Ip', true, { type: 'boolean', - enableQuery: { _id: 'Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data', value: true }, + enableQuery: enableQueryCollectData, }); this.add('Accounts_Block_Failed_Attempts_Until_Block_By_Ip', 50, { type: 'int', - enableQuery: { _id: 'Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data', value: true }, + enableQuery: enableQueryCollectData, }); this.add('Accounts_Block_Failed_Attempts_Time_To_Unblock_By_Ip_In_Minutes', 5, { type: 'int', - enableQuery: { _id: 'Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data', value: true }, + enableQuery: enableQueryCollectData, }); this.add('Accounts_Block_Failed_Attempts_Ip_Whitelist', '', { type: 'string', - enableQuery: { _id: 'Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data', value: true }, + enableQuery: enableQueryCollectData, }); this.add('Accounts_FailedLoginAudit_Enabled', false, { type: 'boolean' }); this.add('Accounts_FailedLoginAudit_Log_Username', false, { type: 'boolean', - enableQuery: { _id: 'Accounts_FailedLoginAudit_Enabled', value: true }, + enableQuery: enableQueryAudit, }); this.add('Accounts_FailedLoginAudit_Log_UserAgent', false, { type: 'boolean', - enableQuery: { _id: 'Accounts_FailedLoginAudit_Enabled', value: true }, + enableQuery: enableQueryAudit, }); this.add('Accounts_FailedLoginAudit_Log_ClientIp', false, { type: 'boolean', - enableQuery: { _id: 'Accounts_FailedLoginAudit_Enabled', value: true }, + enableQuery: enableQueryAudit, }); this.add('Accounts_FailedLoginAudit_Log_ForwardedForIp', false, { type: 'boolean', - enableQuery: { _id: 'Accounts_FailedLoginAudit_Enabled', value: true }, + enableQuery: enableQueryAudit, }); }); }); diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 27cbdcf21703..79345859f2fa 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -57,6 +57,7 @@ "Accounts_BlockedDomainsList": "Blocked Domains List", "Accounts_BlockedDomainsList_Description": "Comma-separated list of blocked domains", "Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data": "Enable collect log in data", + "Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data_Description": "Stores IP and username from log in attempts to a collection on database", "Accounts_Block_Failed_Attempts_Ip_Whitelist": "IP Whitelist", "Accounts_Block_Failed_Attempts_Ip_Whitelist_Description": "Comma-separated list of whitelisted IPs", "Accounts_Block_Failed_Attempts_Time_To_Unblock_By_Ip_In_Minutes": "Time to unblock IP (In Minutes)", diff --git a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index 6517559f7b5e..94c0c204aab7 100644 --- a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -52,6 +52,7 @@ "Accounts_BlockedDomainsList": "Lista de Domínios Bloqueados", "Accounts_BlockedDomainsList_Description": "Lista de domínios bloqueados, separados por vírgulas ", "Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data": "Habilitar a coleta de dados do login", + "Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data_Description": "Salva IP e username das tentativas de login em uma coleção no banco de dados", "Accounts_Block_Failed_Attempts_Ip_Whitelist": "Lista de IPs para não verificar", "Accounts_Block_Failed_Attempts_Ip_Whitelist_Description": "Lista de IPs separados por vírgula", "Accounts_Block_Failed_Attempts_Time_To_Unblock_By_User_In_Minutes": "Tempo para desbloquear o Usuário (Em Minutos)", From 408fd9ab53651af1d6b444a8a898205e881c8fa6 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Sat, 20 Jun 2020 12:50:44 -0300 Subject: [PATCH 7/8] =?UTF-8?q?Disable=20feature=20by=20default=20and=20ch?= =?UTF-8?q?ange=20some=20settings=E2=80=99=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/authentication/server/hooks/login.ts | 11 ++- .../server/lib/logLoginAttempts.ts | 12 +-- .../server/lib/restrictLoginAttempts.ts | 48 ++++++++---- app/authentication/server/startup/index.js | 4 +- app/authentication/server/startup/settings.ts | 74 ++++++++++--------- definition/IServerEvent.ts | 2 +- packages/rocketchat-i18n/i18n/en.i18n.json | 33 +++++---- packages/rocketchat-i18n/i18n/pt-BR.i18n.json | 33 +++++---- 8 files changed, 125 insertions(+), 92 deletions(-) diff --git a/app/authentication/server/hooks/login.ts b/app/authentication/server/hooks/login.ts index 4df5b04f8e9d..5f976958f539 100644 --- a/app/authentication/server/hooks/login.ts +++ b/app/authentication/server/hooks/login.ts @@ -2,20 +2,23 @@ import { Accounts } from 'meteor/accounts-base'; import { ILoginAttempt } from '../ILoginAttempt'; import { saveFailedLoginAttempts, saveSuccessfulLogin } from '../lib/restrictLoginAttempts'; -import { logFailedLoginAttemps } from '../lib/logLoginAttempts'; +import { logFailedLoginAttempts } from '../lib/logLoginAttempts'; import { callbacks } from '../../../callbacks/server'; import { settings } from '../../../settings/server'; Accounts.onLoginFailure((login: ILoginAttempt) => { - if (!settings.get('Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data')) { + if (!settings.get('Block_Multiple_Failed_Logins_Enabled')) { return; } + saveFailedLoginAttempts(login); - logFailedLoginAttemps(login); + logFailedLoginAttempts(login); }); + callbacks.add('afterValidateLogin', (login: ILoginAttempt) => { - if (!settings.get('Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data')) { + if (!settings.get('Block_Multiple_Failed_Logins_Enabled')) { return; } + saveSuccessfulLogin(login); }); diff --git a/app/authentication/server/lib/logLoginAttempts.ts b/app/authentication/server/lib/logLoginAttempts.ts index 128323062d71..18fcb3906baa 100644 --- a/app/authentication/server/lib/logLoginAttempts.ts +++ b/app/authentication/server/lib/logLoginAttempts.ts @@ -1,26 +1,26 @@ import { ILoginAttempt } from '../ILoginAttempt'; import { settings } from '../../../settings/server'; -export const logFailedLoginAttemps = (login: ILoginAttempt): void => { - if (!settings.get('Accounts_FailedLoginAudit_Enabled')) { +export const logFailedLoginAttempts = (login: ILoginAttempt): void => { + if (!settings.get('Login_Logs_Enabled')) { return; } let user = 'unknown'; - if (login.methodArguments[0]?.user?.username && settings.get('Accounts_FailedLoginAudit_Log_Username')) { + if (login.methodArguments[0]?.user?.username && settings.get('Login_Logs_Username')) { user = login.methodArguments[0]?.user?.username; } const { connection } = login; let { clientAddress } = connection; - if (!settings.get('Accounts_FailedLoginAudit_Log_ClientIp')) { + if (!settings.get('Login_Logs_ClientIp')) { clientAddress = '-'; } let forwardedFor = connection.httpHeaders['x-forwarded-for']; - if (!settings.get('Accounts_FailedLoginAudit_Log_ForwardedForIp')) { + if (!settings.get('Login_Logs_ForwardedForIp')) { forwardedFor = '-'; } let userAgent = connection.httpHeaders['user-agent']; - if (!settings.get('Accounts_FailedLoginAudit_Log_UserAgent')) { + if (!settings.get('Login_Logs_UserAgent')) { userAgent = '-'; } console.log('Failed login detected - Username[%s] ClientAddress[%s] ForwardedFor[%s] UserAgent[%s]', user, clientAddress, forwardedFor, userAgent); diff --git a/app/authentication/server/lib/restrictLoginAttempts.ts b/app/authentication/server/lib/restrictLoginAttempts.ts index 9932b1a6f77a..d537363d1add 100644 --- a/app/authentication/server/lib/restrictLoginAttempts.ts +++ b/app/authentication/server/lib/restrictLoginAttempts.ts @@ -9,72 +9,90 @@ import { addMinutesToADate } from '../../../utils/lib/date.helper'; import Sessions from '../../../models/server/raw/Sessions'; export const isValidLoginAttemptByIp = async (ip: string): Promise => { - const whitelist = String(settings.get('Accounts_Block_Failed_Attempts_Ip_Whitelist')).split(','); - if (!settings.get('Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data') - || !settings.get('Accounts_Block_Failed_Login_Attempts_By_Ip') + const whitelist = String(settings.get('Block_Multiple_Failed_Logins_Ip_Whitelist')).split(','); + + if (!settings.get('Block_Multiple_Failed_Logins_Enabled') + || !settings.get('Block_Multiple_Failed_Logins_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'); + + const attemptsUntilBlock = settings.get('Block_Multiple_Failed_Logins_Attempts_Until_Block_By_Ip'); + if (attemptsUntilBlock && failedAttemptsSinceLastLogin < attemptsUntilBlock) { return true; } + const lastAttemptAt = (await ServerEvents.findLastFailedAttemptByIp(ip))?.ts; + if (!lastAttemptAt) { return true; } - const minutesUntilUnblock = settings.get('Accounts_Block_Failed_Attempts_Time_To_Unblock_By_Ip_In_Minutes') as number; + + const minutesUntilUnblock = settings.get('Block_Multiple_Failed_Logins_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 => { - if (!settings.get('Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data') - || !settings.get('Accounts_Block_Failed_Login_Attempts_By_User')) { + if (!settings.get('Block_Multiple_Failed_Logins_Enabled') + || !settings.get('Block_Multiple_Failed_Logins_By_User')) { return true; } - const user = await Users.findOneByUsername(login.methodArguments[0].user?.username) || login.user; + + const user = login.user || await Users.findOneByUsername(login.methodArguments[0].user?.username); + 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'); + + const attemptsUntilBlock = settings.get('Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User'); + if (attemptsUntilBlock && failedAttemptsSinceLastLogin < attemptsUntilBlock) { return true; } + const lastAttemptAt = (await ServerEvents.findLastFailedAttemptByUsername(user.username as string))?.ts; + if (!lastAttemptAt) { return true; } - const minutesUntilUnblock = settings.get('Accounts_Block_Failed_Attempts_Time_To_Unblock_By_User_In_Minutes') as number; + + const minutesUntilUnblock = settings.get('Block_Multiple_Failed_Logins_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 => { - const user: any = { + const user: Partial = { + _id: login.user?._id, 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, + u: user, }); }; diff --git a/app/authentication/server/startup/index.js b/app/authentication/server/startup/index.js index 3f4d0a67cb6c..667fcb240523 100644 --- a/app/authentication/server/startup/index.js +++ b/app/authentication/server/startup/index.js @@ -295,11 +295,13 @@ Accounts.insertUserDoc = _.wrap(Accounts.insertUserDoc, function(insertUserDoc, Accounts.validateLoginAttempt(function(login) { login = callbacks.run('beforeValidateLogin', login); - if (!Promise.await(isValidLoginAttemptByIp(login.connection.clientAddress))) { + + if (!Promise.await(isValidLoginAttemptByIp(login.connection?.clientAddress))) { throw new Meteor.Error('error-login-blocked-for-ip', 'Login has been temporarily blocked For IP', { function: 'Accounts.validateLoginAttempt', }); } + if (!Promise.await(isValidAttemptByUser(login))) { throw new Meteor.Error('error-login-blocked-for-user', 'Login has been temporarily blocked For User', { function: 'Accounts.validateLoginAttempt', diff --git a/app/authentication/server/startup/settings.ts b/app/authentication/server/startup/settings.ts index d3e0e9595707..a3c524e50e36 100644 --- a/app/authentication/server/startup/settings.ts +++ b/app/authentication/server/startup/settings.ts @@ -4,58 +4,66 @@ import { settings } from '../../../settings/server'; Meteor.startup(function() { settings.addGroup('Accounts', function() { - const enableQueryCollectData = { _id: 'Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data', value: true }; - const enableQueryAudit = { _id: 'Accounts_FailedLoginAudit_Enabled', value: true }; - this.section('Failed_Login_Attempts', function() { - this.add('Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data', true, { + const enableQueryCollectData = { _id: 'Block_Multiple_Failed_Logins_Enabled', value: true }; + + this.section('Login_Attempts', function() { + this.add('Block_Multiple_Failed_Logins_Enabled', false, { type: 'boolean', }); - this.add('Accounts_Block_Failed_Login_Attempts_By_User', true, { + + this.add('Block_Multiple_Failed_Logins_By_User', true, { type: 'boolean', enableQuery: enableQueryCollectData, }); - this.add('Accounts_Block_Failed_Attempts_Until_Block_By_User', 10, { + + const enableQueryByUser = [enableQueryCollectData, { _id: 'Block_Multiple_Failed_Logins_By_User', value: true }]; + + this.add('Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User', 10, { type: 'int', - enableQuery: enableQueryCollectData, + enableQuery: enableQueryByUser, }); - this.add('Accounts_Block_Failed_Attempts_Time_To_Unblock_By_User_In_Minutes', 5, { + + this.add('Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes', 5, { type: 'int', - enableQuery: enableQueryCollectData, + enableQuery: enableQueryByUser, }); - this.add('Accounts_Block_Failed_Login_Attempts_By_Ip', true, { + + this.add('Block_Multiple_Failed_Logins_By_Ip', true, { type: 'boolean', enableQuery: enableQueryCollectData, }); - this.add('Accounts_Block_Failed_Attempts_Until_Block_By_Ip', 50, { + + const enableQueryByIp = [enableQueryCollectData, { _id: 'Block_Multiple_Failed_Logins_By_Ip', value: true }]; + + this.add('Block_Multiple_Failed_Logins_Attempts_Until_Block_By_Ip', 50, { type: 'int', - enableQuery: enableQueryCollectData, + enableQuery: enableQueryByIp, }); - this.add('Accounts_Block_Failed_Attempts_Time_To_Unblock_By_Ip_In_Minutes', 5, { + + this.add('Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes', 5, { type: 'int', - enableQuery: enableQueryCollectData, + enableQuery: enableQueryByIp, }); - this.add('Accounts_Block_Failed_Attempts_Ip_Whitelist', '', { + + this.add('Block_Multiple_Failed_Logins_Ip_Whitelist', '', { type: 'string', - enableQuery: enableQueryCollectData, + enableQuery: enableQueryByIp, }); + }); - this.add('Accounts_FailedLoginAudit_Enabled', false, { type: 'boolean' }); - this.add('Accounts_FailedLoginAudit_Log_Username', false, { - type: 'boolean', - enableQuery: enableQueryAudit, - }); - this.add('Accounts_FailedLoginAudit_Log_UserAgent', false, { - type: 'boolean', - enableQuery: enableQueryAudit, - }); - this.add('Accounts_FailedLoginAudit_Log_ClientIp', false, { - type: 'boolean', - enableQuery: enableQueryAudit, - }); - this.add('Accounts_FailedLoginAudit_Log_ForwardedForIp', false, { - type: 'boolean', - enableQuery: enableQueryAudit, - }); + + this.section('Login_Logs', function() { + const enableQueryAudit = { _id: 'Login_Logs_Enabled', value: true }; + + this.add('Login_Logs_Enabled', false, { type: 'boolean' }); + + this.add('Login_Logs_Username', false, { type: 'boolean', enableQuery: enableQueryAudit }); + + this.add('Login_Logs_UserAgent', false, { type: 'boolean', enableQuery: enableQueryAudit }); + + this.add('Login_Logs_ClientIp', false, { type: 'boolean', enableQuery: enableQueryAudit }); + + this.add('Login_Logs_ForwardedForIp', false, { type: 'boolean', enableQuery: enableQueryAudit }); }); }); }); diff --git a/definition/IServerEvent.ts b/definition/IServerEvent.ts index aebebff1c603..edc831fb7c56 100644 --- a/definition/IServerEvent.ts +++ b/definition/IServerEvent.ts @@ -10,5 +10,5 @@ export interface IServerEvent { t: IServerEventType; ts: Date; ip: string; - u: IUser; + u?: Partial; } diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 9dcb8eb1bbcc..a2de0b64a784 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -56,16 +56,16 @@ "Accounts_AvatarExternalProviderUrl_Description": "Example: `https://acme.com/api/v1/{username}`", "Accounts_BlockedDomainsList": "Blocked Domains List", "Accounts_BlockedDomainsList_Description": "Comma-separated list of blocked domains", - "Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data": "Enable collect log in data", - "Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data_Description": "Stores IP and username from log in attempts to a collection on database", - "Accounts_Block_Failed_Attempts_Ip_Whitelist": "IP Whitelist", - "Accounts_Block_Failed_Attempts_Ip_Whitelist_Description": "Comma-separated list of whitelisted IPs", - "Accounts_Block_Failed_Attempts_Time_To_Unblock_By_Ip_In_Minutes": "Time to unblock IP (In Minutes)", - "Accounts_Block_Failed_Attempts_Time_To_Unblock_By_User_In_Minutes": "Time to unblock User (In Minutes)", - "Accounts_Block_Failed_Attempts_Until_Block_By_Ip": "How many failed attempts until block by IP", - "Accounts_Block_Failed_Attempts_Until_Block_By_User": "How many failed attempts until block by User", - "Accounts_Block_Failed_Login_Attempts_By_Ip": "Block failed login attempts by IP", - "Accounts_Block_Failed_Login_Attempts_By_User": "Block failed login attempts by Username", + "Block_Multiple_Failed_Logins_Enabled": "Enable collect log in data", + "Block_Multiple_Failed_Logins_Enable_Collect_Login_data_Description": "Stores IP and username from log in attempts to a collection on database", + "Block_Multiple_Failed_Logins_Ip_Whitelist": "IP Whitelist", + "Block_Multiple_Failed_Logins_Ip_Whitelist_Description": "Comma-separated list of whitelisted IPs", + "Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes": "Time to unblock IP (In Minutes)", + "Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes": "Time to unblock User (In Minutes)", + "Block_Multiple_Failed_Logins_Attempts_Until_Block_By_Ip": "How many failed attempts until block by IP", + "Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User": "How many failed attempts until block by User", + "Block_Multiple_Failed_Logins_By_Ip": "Block failed login attempts by IP", + "Block_Multiple_Failed_Logins_By_User": "Block failed login attempts by Username", "Accounts_BlockedUsernameList": "Blocked Username List", "Accounts_BlockedUsernameList_Description": "Comma-separated list of blocked usernames (case-insensitive)", "Accounts_CustomFields_Description": "Should be a valid JSON where keys are the field names containing a dictionary of field settings. Example:
{\n \"role\": {\n  \"type\": \"select\",\n  \"defaultValue\": \"student\",\n  \"options\": [\"teacher\", \"student\"],\n  \"required\": true,\n  \"modifyRecordField\": {\n   \"array\": true,\n   \"field\": \"roles\"\n  }\n },\n \"twitter\": {\n  \"type\": \"text\",\n  \"required\": true,\n  \"minLength\": 2,\n  \"maxLength\": 10\n }\n} ", @@ -89,11 +89,11 @@ "Accounts_Enrollment_Email_Subject_Default": "Welcome to [Site_Name]", "Accounts_Enrollment_Email": "Enrollment Email", "Accounts_Enrollment_Email_Description": "You may use the following placeholders:
  • [name], [fname], [lname] for the user's full name, first name or last name, respectively.
  • [email] for the user's email.
  • [Site_Name] and [Site_URL] for the Application Name and URL respectively.
", - "Accounts_FailedLoginAudit_Enabled": "Log (on console) failed login attempts", - "Accounts_FailedLoginAudit_Log_ClientIp": "Show Client IP on failed login attempts logs", - "Accounts_FailedLoginAudit_Log_ForwardedForIp": "Show Forwarded IP on failed login attempts logs", - "Accounts_FailedLoginAudit_Log_Username": "Show Username on failed login attempts logs", - "Accounts_FailedLoginAudit_Log_UserAgent": "Show UserAgent on failed login attempts logs", + "Login_Logs_Enabled": "Log (on console) failed login attempts", + "Login_Logs_ClientIp": "Show Client IP on failed login attempts logs", + "Login_Logs_ForwardedForIp": "Show Forwarded IP on failed login attempts logs", + "Login_Logs_Username": "Show Username on failed login attempts logs", + "Login_Logs_UserAgent": "Show UserAgent on failed login attempts logs", "Accounts_ForgetUserSessionOnWindowClose": "Forget User Session on Window Close", "Accounts_Iframe_api_method": "Api Method", "Accounts_Iframe_api_url": "API URL", @@ -1547,7 +1547,7 @@ "External_Service": "External Service", "Facebook_Page": "Facebook Page", "Failed": "Failed", - "Failed_Login_Attempts": "Failed Login Attempts", + "Login_Attempts": "Failed Login Attempts", "Failed_to_activate_invite_token": "Failed to activate invite token", "Failed_To_Download_Files": "Failed to download files", "Failed_to_generate_invite_link": "Failed to generate invite link", @@ -2281,6 +2281,7 @@ "Log_View_Limit": "Log View Limit", "Logged_out_of_other_clients_successfully": "Logged out of other clients successfully", "Login": "Login", + "Login_Logs": "Login Logs", "Login_with": "Login with %s", "Logistics": "Logistics", "Logout": "Logout", diff --git a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index 8f57cde336c4..d5d3ae8eb9ac 100644 --- a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -51,16 +51,16 @@ "Accounts_AvatarExternalProviderUrl_Description": "Exemplo: `https://acme.com/api/v1/ {username}`", "Accounts_BlockedDomainsList": "Lista de Domínios Bloqueados", "Accounts_BlockedDomainsList_Description": "Lista de domínios bloqueados, separados por vírgulas ", - "Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data": "Habilitar a coleta de dados do login", - "Accounts_Block_Failed_Login_Attempts_Enable_Collect_Login_data_Description": "Salva IP e username das tentativas de login em uma coleção no banco de dados", - "Accounts_Block_Failed_Attempts_Ip_Whitelist": "Lista de IPs para não verificar", - "Accounts_Block_Failed_Attempts_Ip_Whitelist_Description": "Lista de IPs separados por vírgula", - "Accounts_Block_Failed_Attempts_Time_To_Unblock_By_User_In_Minutes": "Tempo para desbloquear o Usuário (Em Minutos)", - "Accounts_Block_Failed_Attempts_Time_To_Unblock_By_Ip_In_Minutes": "Tempo para desbloquear o IP (Em Minutos)", - "Accounts_Block_Failed_Attempts_Until_Block_By_Ip": "Quantas tentativas falhas até bloquear por IP", - "Accounts_Block_Failed_Attempts_Until_Block_By_User": "Quantas tentativas falhas até bloquear por Usuário", - "Accounts_Block_Failed_Login_Attempts_By_User": "Bloquear tentativas falhas de login por Usuário", - "Accounts_Block_Failed_Login_Attempts_By_Ip": "Bloquear tentativas falhas de login por IP", + "Block_Multiple_Failed_Logins_Enabled": "Habilitar a coleta de dados do login", + "Block_Multiple_Failed_Logins_Enable_Collect_Login_data_Description": "Salva IP e username das tentativas de login em uma coleção no banco de dados", + "Block_Multiple_Failed_Logins_Ip_Whitelist": "Lista de IPs para não verificar", + "Block_Multiple_Failed_Logins_Ip_Whitelist_Description": "Lista de IPs separados por vírgula", + "Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes": "Tempo para desbloquear o Usuário (Em Minutos)", + "Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes": "Tempo para desbloquear o IP (Em Minutos)", + "Block_Multiple_Failed_Logins_Attempts_Until_Block_By_Ip": "Quantas tentativas falhas até bloquear por IP", + "Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User": "Quantas tentativas falhas até bloquear por Usuário", + "Block_Multiple_Failed_Logins_By_User": "Bloquear tentativas falhas de login por Usuário", + "Block_Multiple_Failed_Logins_By_Ip": "Bloquear tentativas falhas de login por IP", "Accounts_BlockedUsernameList": "Lista de Nomes de Usuário Bloqueados", "Accounts_BlockedUsernameList_Description": "Lista de nomes de usuários bloqueados, separada por vírgulas (não diferencia maiúsculas)", "Accounts_CustomFields_Description": "Deve ser um JSON válido onde as chaves são os nomes de campos contendo um dicionário de configuração de campos. Exemplo:
{\n \"role\": {\n  \"type\": \"select\",\n  \"defaultValue\": \"student\",\n  \"options\": [\"teacher\", \"student\"],\n  \"required\": true,\n  \"modifyRecordField\": {\n   \"array\": true,\n   \"field\": \"roles\"\n  }\n },\n \"twitter\": {\n  \"type\": \"text\",\n  \"required\": true,\n  \"minLength\": 2,\n  \"maxLength\": 10\n }\n} ", @@ -84,11 +84,12 @@ "Accounts_Enrollment_Email_Subject_Default": "Bem-vindo ao [Site_Name]", "Accounts_Enrollment_Email": "E-mail de Inscrição", "Accounts_Enrollment_Email_Description": "Você pode usar os seguintes placeholders:
  • [name], [fname], [lname] para o nome completo do usuário, primeiro nome ou sobrenome, respectivamente.
  • [email] para o e-mail do usuário.
  • [Site_Name] e [Site_URL] para o Nome da Aplicação e URL, respectivamente.
", - "Accounts_FailedLoginAudit_Enabled": "Registrar(no console) tentativas falhas de login", - "Accounts_FailedLoginAudit_Log_ClientIp": "Mostrar o IP do cliente nos registros de tentativas falhas de login", - "Accounts_FailedLoginAudit_Log_ForwardedForIp": "Mostrar o 'ForwardedFor' IP nos logs de tentativas falhas de login", - "Accounts_FailedLoginAudit_Log_Username": "Mostrar o Nome do usuário nos logs de tentativas falhas de login", - "Accounts_FailedLoginAudit_Log_UserAgent": "Mostrar o 'UserAgent' nos logs de tentativas falhas de login", + "Login_Logs": "Registrar tentativas login", + "Login_Logs_Enabled": "Registrar(no console) tentativas falhas de login", + "Login_Logs_ClientIp": "Mostrar o IP do cliente nos registros de tentativas falhas de login", + "Login_Logs_ForwardedForIp": "Mostrar o 'ForwardedFor' IP nos logs de tentativas falhas de login", + "Login_Logs_Username": "Mostrar o Nome do usuário nos logs de tentativas falhas de login", + "Login_Logs_UserAgent": "Mostrar o 'UserAgent' nos logs de tentativas falhas de login", "Accounts_ForgetUserSessionOnWindowClose": "Esqueça a sessão do usuário ao fechar a janela", "Accounts_Iframe_api_method": "Método HTTP da API", "Accounts_Iframe_api_url": "URL da API", @@ -1414,7 +1415,7 @@ "External_Queue_Service_URL": "URL do Serviço de Fila Externa", "External_Service": "Serviço Externo", "Facebook_Page": "Página do Facebook", - "Failed_Login_Attempts": "Tentativas falhas de Login", + "Login_Attempts": "Tentativas falhas de Login", "Failed_to_activate_invite_token": "Falha na ativação do token de convite", "Failed_to_generate_invite_link": "Falha na geração do link de convite", "Failed_to_validate_invite_token": "Falha na validação do token de convite", From f0b3b1cd5da0eb6b61da3d4c01f523288db01a33 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Sat, 20 Jun 2020 13:03:14 -0300 Subject: [PATCH 8/8] Allow log failed logins without enable the block login feature --- app/authentication/server/hooks/login.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/authentication/server/hooks/login.ts b/app/authentication/server/hooks/login.ts index 5f976958f539..cfa7cd32dcb1 100644 --- a/app/authentication/server/hooks/login.ts +++ b/app/authentication/server/hooks/login.ts @@ -7,11 +7,10 @@ import { callbacks } from '../../../callbacks/server'; import { settings } from '../../../settings/server'; Accounts.onLoginFailure((login: ILoginAttempt) => { - if (!settings.get('Block_Multiple_Failed_Logins_Enabled')) { - return; + if (settings.get('Block_Multiple_Failed_Logins_Enabled')) { + saveFailedLoginAttempts(login); } - saveFailedLoginAttempts(login); logFailedLoginAttempts(login); });