Skip to content

Commit

Permalink
fix: login blocked after block time (#30018)
Browse files Browse the repository at this point in the history
  • Loading branch information
sampaiodiego committed Aug 18, 2023
1 parent f211388 commit 1589279
Show file tree
Hide file tree
Showing 8 changed files with 99 additions and 48 deletions.
5 changes: 5 additions & 0 deletions .changeset/six-buckets-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fix users not able to login after block time perdiod has passed
2 changes: 2 additions & 0 deletions apps/meteor/app/authentication/server/ILoginAttempt.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { MeteorError } from '@rocket.chat/core-services';
import type { IUser, IMethodConnection } from '@rocket.chat/core-typings';

interface IMethodArgument {
Expand All @@ -22,4 +23,5 @@ export interface ILoginAttempt {
methodArguments: IMethodArgument[];
connection: IMethodConnection;
user?: IUser;
error?: MeteorError;
}
9 changes: 8 additions & 1 deletion apps/meteor/app/authentication/server/hooks/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@ import type { ILoginAttempt } from '../ILoginAttempt';
import { logFailedLoginAttempts } from '../lib/logLoginAttempts';
import { saveFailedLoginAttempts, saveSuccessfulLogin } from '../lib/restrictLoginAttempts';

const ignoredErrorTypes = ['totp-required', 'error-login-blocked-for-user'];

Accounts.onLoginFailure(async (login: ILoginAttempt) => {
if (settings.get('Block_Multiple_Failed_Logins_Enabled')) {
// do not save specific failed login attempts
if (
settings.get('Block_Multiple_Failed_Logins_Enabled') &&
login.error?.error &&
!ignoredErrorTypes.includes(String(login.error.error))
) {
await saveFailedLoginAttempts(login);
}

Expand Down
89 changes: 50 additions & 39 deletions apps/meteor/app/authentication/server/lib/restrictLoginAttempts.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { IServerEvent } from '@rocket.chat/core-typings';
import { ServerEventType } from '@rocket.chat/core-typings';
import { Rooms, ServerEvents, Sessions, Users } from '@rocket.chat/models';
import moment from 'moment';
import { Rooms, ServerEvents, Users } from '@rocket.chat/models';

import { addMinutesToADate } from '../../../../lib/utils/addMinutesToADate';
import { getClientAddress } from '../../../../server/lib/getClientAddress';
Expand Down Expand Up @@ -58,36 +57,42 @@ export const isValidLoginAttemptByIp = async (ip: string): Promise<boolean> => {
return true;
}

const lastLogin = await Sessions.findLastLoginByIp(ip);
let failedAttemptsSinceLastLogin;

if (!lastLogin?.loginAt) {
failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByIp(ip);
} else {
failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByIpSince(ip, new Date(lastLogin.loginAt));
}

// misconfigured
const attemptsUntilBlock = settings.get<number>('Block_Multiple_Failed_Logins_Attempts_Until_Block_By_Ip');
if (!attemptsUntilBlock) {
return true;
}

if (attemptsUntilBlock && failedAttemptsSinceLastLogin < attemptsUntilBlock) {
// if user never failed to login, then it's valid
const lastFailedAttemptAt = (await ServerEvents.findLastFailedAttemptByIp(ip))?.ts;
if (!lastFailedAttemptAt) {
return true;
}

const lastAttemptAt = (await ServerEvents.findLastFailedAttemptByIp(ip))?.ts;
const minutesUntilUnblock = settings.get<number>('Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes');

if (!lastAttemptAt) {
const lockoutTimeStart = addMinutesToADate(new Date(), minutesUntilUnblock * -1);
const lastSuccessfulAttemptAt = (await ServerEvents.findLastSuccessfulAttemptByIp(ip))?.ts;

// successful logins should reset the counter
const startTime = lastSuccessfulAttemptAt
? new Date(Math.max(lockoutTimeStart.getTime(), lastSuccessfulAttemptAt.getTime()))
: lockoutTimeStart;

const failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByIpSince(ip, startTime);

// if user didn't reach the threshold, then it's valid
if (failedAttemptsSinceLastLogin < attemptsUntilBlock) {
return true;
}

const minutesUntilUnblock = settings.get('Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes') as number;
const willBeBlockedUntil = addMinutesToADate(new Date(lastAttemptAt), minutesUntilUnblock);
const isValid = moment(new Date()).isSameOrAfter(willBeBlockedUntil);
if (settings.get('Block_Multiple_Failed_Logins_Notify_Failed')) {
const willBeBlockedUntil = addMinutesToADate(new Date(lastFailedAttemptAt), minutesUntilUnblock);

if (settings.get('Block_Multiple_Failed_Logins_Notify_Failed') && !isValid) {
await notifyFailedLogin(ip, willBeBlockedUntil, failedAttemptsSinceLastLogin);
}

return isValid;
return false;
};

export const isValidAttemptByUser = async (login: ILoginAttempt): Promise<boolean> => {
Expand All @@ -96,41 +101,47 @@ export const isValidAttemptByUser = async (login: ILoginAttempt): Promise<boolea
}

const loginUsername = login.methodArguments[0].user?.username;
const user = login.user || (loginUsername && (await Users.findOneByUsername(loginUsername))) || undefined;

if (!user?.username) {
if (!loginUsername) {
return true;
}

let failedAttemptsSinceLastLogin;

if (!user?.lastLogin) {
failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByUsername(user.username);
} else {
failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByUsernameSince(user.username, new Date(user.lastLogin));
}

// misconfigured
const attemptsUntilBlock = settings.get<number>('Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User');
if (!attemptsUntilBlock) {
return true;
}

if (attemptsUntilBlock && failedAttemptsSinceLastLogin < attemptsUntilBlock) {
// if user never failed to login, then it's valid
const lastFailedAttemptAt = (await ServerEvents.findLastFailedAttemptByUsername(loginUsername))?.ts;
if (!lastFailedAttemptAt) {
return true;
}

const lastAttemptAt = (await ServerEvents.findLastFailedAttemptByUsername(user.username as string))?.ts;
const minutesUntilUnblock = settings.get<number>('Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes');

const lockoutTimeStart = addMinutesToADate(new Date(), minutesUntilUnblock * -1);
const lastSuccessfulAttemptAt = (await ServerEvents.findLastSuccessfulAttemptByUsername(loginUsername))?.ts;

// succesful logins should reset the counter
const startTime = lastSuccessfulAttemptAt
? new Date(Math.max(lockoutTimeStart.getTime(), lastSuccessfulAttemptAt.getTime()))
: lockoutTimeStart;

// get total failed attempts during the lockout time
const failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByUsernameSince(loginUsername, startTime);

if (!lastAttemptAt) {
// if user didn't reach the threshold, then it's valid
if (failedAttemptsSinceLastLogin < attemptsUntilBlock) {
return true;
}

const minutesUntilUnblock = settings.get('Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes') as number;
const willBeBlockedUntil = addMinutesToADate(new Date(lastAttemptAt), minutesUntilUnblock);
const isValid = moment(new Date()).isSameOrAfter(willBeBlockedUntil);
if (settings.get('Block_Multiple_Failed_Logins_Notify_Failed')) {
const willBeBlockedUntil = addMinutesToADate(new Date(lastFailedAttemptAt), minutesUntilUnblock);

if (settings.get('Block_Multiple_Failed_Logins_Notify_Failed') && !isValid) {
await notifyFailedLogin(user.username, willBeBlockedUntil, failedAttemptsSinceLastLogin);
await notifyFailedLogin(loginUsername, willBeBlockedUntil, failedAttemptsSinceLastLogin);
}

return isValid;
return false;
};

export const saveFailedLoginAttempts = async (login: ILoginAttempt): Promise<void> => {
Expand Down
10 changes: 6 additions & 4 deletions apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -751,16 +751,18 @@
"Bio": "Bio",
"Bio_Placeholder": "Bio Placeholder",
"Block": "Block",
"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_Attempts_Until_Block_By_Ip": "Amount of failed attempts before blocking IP address",
"Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User": "Amount of failed attempts before blocking 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",
"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_Enabled": "Enable collect log in data",
"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_Time_To_Unblock_By_Ip_In_Minutes": "Duration of IP address block (in minutes)",
"Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes_Description": "This is the time the IP address is blocked by, and the time in which the failed attempts can happen before the counter resets",
"Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes": "Duration of user block (in minutes)",
"Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes_Description": "This is the time the user is blocked by, and the time in which the failed attempts can happen before the counter resets",
"Block_Multiple_Failed_Logins_Notify_Failed": "Notify of failed login attempts",
"Block_Multiple_Failed_Logins_Notify_Failed_Channel": "Channel to send the notifications",
"Block_Multiple_Failed_Logins_Notify_Failed_Channel_Desc": "This is where notifications will be received. Make sure the channel exists. The channel name should not include # symbol",
Expand Down
10 changes: 6 additions & 4 deletions apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -674,16 +674,18 @@
"Better": "Melhor",
"Bio": "Biografia",
"Bio_Placeholder": "Placeholder da biografia",
"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_Attempts_Until_Block_By_Ip": "Quantidade de tentativas falhas antes de bloquear por IP",
"Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User": "Quantidade de tentativas falhas antes de bloquear por usuário",
"Block_Multiple_Failed_Logins_By_Ip": "Bloquear tentativas falhas de login por IP",
"Block_Multiple_Failed_Logins_By_User": "Bloquear tentativas falhas de login por nome de usuário",
"Block_Multiple_Failed_Logins_Enable_Collect_Login_data_Description": "Armazena IP e nome de usuário das tentativas de login em uma coleção no banco de dados",
"Block_Multiple_Failed_Logins_Enabled": "Habilitar a coleta de dados do login",
"Block_Multiple_Failed_Logins_Ip_Whitelist": "Lista de IPs permitidos",
"Block_Multiple_Failed_Logins_Ip_Whitelist_Description": "Lista de IPs permitidos separados por vírgulas",
"Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes": "Tempo para desbloquear o IP (em minutos)",
"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": "Duração do bloqueio de IP (em minutos)",
"Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes_Description": "Esse é o tempo que o IP é bloqueado, e o tempo em que as tentativas falhas podem ocorrer antes do contador ser reiniciado",
"Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes": "Duração do bloqueio de usuário (em minutos)",
"Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes_Description": "Esse é o tempo que o usuário é bloqueado, e o tempo em que as tentativas falhas podem ocorrer antes do contador ser reiniciado",
"Block_Multiple_Failed_Logins_Notify_Failed": "Notificar tentativas falhas de login",
"Block_Multiple_Failed_Logins_Notify_Failed_Channel": "Canal para enviar notificações",
"Block_Multiple_Failed_Logins_Notify_Failed_Channel_Desc": "Aqui é o local em que as notificações serão recebidas. Certifique-se de que o canal exista. O nome do canal não deve incluir o símbolo #",
Expand Down
20 changes: 20 additions & 0 deletions apps/meteor/server/models/raw/ServerEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,26 @@ export class ServerEventsRaw extends BaseRaw<IServerEvent> implements IServerEve
);
}

async findLastSuccessfulAttemptByIp(ip: string): Promise<IServerEvent | null> {
return this.findOne<IServerEvent>(
{
ip,
t: ServerEventType.LOGIN,
},
{ sort: { ts: -1 } },
);
}

async findLastSuccessfulAttemptByUsername(username: string): Promise<IServerEvent | null> {
return this.findOne<IServerEvent>(
{
'u.username': username,
't': ServerEventType.LOGIN,
},
{ sort: { ts: -1 } },
);
}

async countFailedAttemptsByUsernameSince(username: string, since: Date): Promise<number> {
return this.find({
'u.username': username,
Expand Down
2 changes: 2 additions & 0 deletions packages/model-typings/src/models/IServerEventsModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { IBaseModel } from './IBaseModel';
export interface IServerEventsModel extends IBaseModel<IServerEvent> {
findLastFailedAttemptByIp(ip: string): Promise<IServerEvent | null>;
findLastFailedAttemptByUsername(username: string): Promise<IServerEvent | null>;
findLastSuccessfulAttemptByIp(ip: string): Promise<IServerEvent | null>;
findLastSuccessfulAttemptByUsername(username: string): Promise<IServerEvent | null>;
countFailedAttemptsByUsernameSince(username: string, since: Date): Promise<number>;
countFailedAttemptsByIpSince(ip: string, since: Date): Promise<number>;
countFailedAttemptsByIp(ip: string): Promise<number>;
Expand Down

0 comments on commit 1589279

Please sign in to comment.