[NEW] Add ability to block failed login attempts by user and IP (#17783)
parent
143ff3ab58
commit
0aada1571e
@ -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; |
||||
} |
@ -0,0 +1,23 @@ |
||||
import { Accounts } from 'meteor/accounts-base'; |
||||
|
||||
import { ILoginAttempt } from '../ILoginAttempt'; |
||||
import { saveFailedLoginAttempts, saveSuccessfulLogin } from '../lib/restrictLoginAttempts'; |
||||
import { logFailedLoginAttempts } from '../lib/logLoginAttempts'; |
||||
import { callbacks } from '../../../callbacks/server'; |
||||
import { settings } from '../../../settings/server'; |
||||
|
||||
Accounts.onLoginFailure((login: ILoginAttempt) => { |
||||
if (settings.get('Block_Multiple_Failed_Logins_Enabled')) { |
||||
saveFailedLoginAttempts(login); |
||||
} |
||||
|
||||
logFailedLoginAttempts(login); |
||||
}); |
||||
|
||||
callbacks.add('afterValidateLogin', (login: ILoginAttempt) => { |
||||
if (!settings.get('Block_Multiple_Failed_Logins_Enabled')) { |
||||
return; |
||||
} |
||||
|
||||
saveSuccessfulLogin(login); |
||||
}); |
@ -0,0 +1,3 @@ |
||||
import './hooks/login'; |
||||
|
||||
export * from './startup'; |
@ -0,0 +1,27 @@ |
||||
import { ILoginAttempt } from '../ILoginAttempt'; |
||||
import { settings } from '../../../settings/server'; |
||||
|
||||
export const logFailedLoginAttempts = (login: ILoginAttempt): void => { |
||||
if (!settings.get('Login_Logs_Enabled')) { |
||||
return; |
||||
} |
||||
|
||||
let user = 'unknown'; |
||||
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('Login_Logs_ClientIp')) { |
||||
clientAddress = '-'; |
||||
} |
||||
let forwardedFor = connection.httpHeaders['x-forwarded-for']; |
||||
if (!settings.get('Login_Logs_ForwardedForIp')) { |
||||
forwardedFor = '-'; |
||||
} |
||||
let userAgent = connection.httpHeaders['user-agent']; |
||||
if (!settings.get('Login_Logs_UserAgent')) { |
||||
userAgent = '-'; |
||||
} |
||||
console.log('Failed login detected - Username[%s] ClientAddress[%s] ForwardedFor[%s] UserAgent[%s]', user, clientAddress, forwardedFor, userAgent); |
||||
}; |
@ -0,0 +1,106 @@ |
||||
import moment from 'moment'; |
||||
|
||||
import { ILoginAttempt } from '../ILoginAttempt'; |
||||
import { ServerEvents, Users } from '../../../models/server/raw'; |
||||
import { IServerEventType } from '../../../../definition/IServerEvent'; |
||||
import { IUser } from '../../../../definition/IUser'; |
||||
import { settings } from '../../../settings/server'; |
||||
import { addMinutesToADate } from '../../../utils/lib/date.helper'; |
||||
import Sessions from '../../../models/server/raw/Sessions'; |
||||
|
||||
export const isValidLoginAttemptByIp = async (ip: string): Promise<boolean> => { |
||||
const whitelist = String(settings.get('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('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('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<boolean> => { |
||||
if (!settings.get('Block_Multiple_Failed_Logins_Enabled') |
||||
|| !settings.get('Block_Multiple_Failed_Logins_By_User')) { |
||||
return true; |
||||
} |
||||
|
||||
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('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('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<void> => { |
||||
const user: Partial<IUser> = { |
||||
_id: login.user?._id, |
||||
username: login.user?.username || login.methodArguments[0].user?.username, |
||||
}; |
||||
|
||||
await ServerEvents.insertOne({ |
||||
ip: login.connection.clientAddress, |
||||
t: IServerEventType.FAILED_LOGIN_ATTEMPT, |
||||
ts: new Date(), |
||||
u: user, |
||||
}); |
||||
}; |
||||
|
||||
export const saveSuccessfulLogin = async (login: ILoginAttempt): Promise<void> => { |
||||
await ServerEvents.insertOne({ |
||||
ip: login.connection.clientAddress, |
||||
t: IServerEventType.LOGIN, |
||||
ts: new Date(), |
||||
u: login.user, |
||||
}); |
||||
}; |
@ -0,0 +1,69 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
|
||||
import { settings } from '../../../settings/server'; |
||||
|
||||
Meteor.startup(function() { |
||||
settings.addGroup('Accounts', function() { |
||||
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('Block_Multiple_Failed_Logins_By_User', true, { |
||||
type: 'boolean', |
||||
enableQuery: enableQueryCollectData, |
||||
}); |
||||
|
||||
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: enableQueryByUser, |
||||
}); |
||||
|
||||
this.add('Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes', 5, { |
||||
type: 'int', |
||||
enableQuery: enableQueryByUser, |
||||
}); |
||||
|
||||
this.add('Block_Multiple_Failed_Logins_By_Ip', true, { |
||||
type: 'boolean', |
||||
enableQuery: enableQueryCollectData, |
||||
}); |
||||
|
||||
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: enableQueryByIp, |
||||
}); |
||||
|
||||
this.add('Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes', 5, { |
||||
type: 'int', |
||||
enableQuery: enableQueryByIp, |
||||
}); |
||||
|
||||
this.add('Block_Multiple_Failed_Logins_Ip_Whitelist', '', { |
||||
type: 'string', |
||||
enableQuery: enableQueryByIp, |
||||
}); |
||||
}); |
||||
|
||||
|
||||
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 }); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,11 @@ |
||||
import { Base } from './_Base'; |
||||
|
||||
export class ServerEvents extends Base { |
||||
constructor() { |
||||
super('server_events'); |
||||
this.tryEnsureIndex({ t: 1, ip: 1, ts: -1 }); |
||||
this.tryEnsureIndex({ t: 1, 'u.username': 1, ts: -1 }); |
||||
} |
||||
} |
||||
|
||||
export default new ServerEvents(); |
@ -0,0 +1,67 @@ |
||||
import { Collection, ObjectId } from 'mongodb'; |
||||
|
||||
import { BaseRaw } from './BaseRaw'; |
||||
import { IServerEvent, IServerEventType } from '../../../../definition/IServerEvent'; |
||||
import { IUser } from '../../../../definition/IUser'; |
||||
|
||||
export class ServerEventsRaw extends BaseRaw { |
||||
public readonly col!: Collection<IServerEvent>; |
||||
|
||||
async insertOne(data: Omit<IServerEvent, '_id'>): Promise<any> { |
||||
if (data.u) { |
||||
data.u = { _id: data.u._id, username: data.u.username } as IUser; |
||||
} |
||||
return this.col.insertOne({ |
||||
_id: new ObjectId().toHexString(), |
||||
...data, |
||||
}); |
||||
} |
||||
|
||||
async findLastFailedAttemptByIp(ip: string): Promise<IServerEvent | null> { |
||||
return this.col.findOne({ |
||||
ip, |
||||
t: IServerEventType.FAILED_LOGIN_ATTEMPT, |
||||
}, { sort: { ts: -1 } }); |
||||
} |
||||
|
||||
async findLastFailedAttemptByUsername(username: string): Promise<IServerEvent | null> { |
||||
return this.col.findOne({ |
||||
'u.username': username, |
||||
t: IServerEventType.FAILED_LOGIN_ATTEMPT, |
||||
}, { sort: { ts: -1 } }); |
||||
} |
||||
|
||||
async countFailedAttemptsByUsernameSince(username: string, since: Date): Promise<number> { |
||||
return this.col.find({ |
||||
'u.username': username, |
||||
t: IServerEventType.FAILED_LOGIN_ATTEMPT, |
||||
ts: { |
||||
$gte: since, |
||||
}, |
||||
}).count(); |
||||
} |
||||
|
||||
countFailedAttemptsByIpSince(ip: string, since: Date): Promise<number> { |
||||
return this.col.find({ |
||||
ip, |
||||
t: IServerEventType.FAILED_LOGIN_ATTEMPT, |
||||
ts: { |
||||
$gte: since, |
||||
}, |
||||
}).count(); |
||||
} |
||||
|
||||
countFailedAttemptsByIp(ip: string): Promise<number> { |
||||
return this.col.find({ |
||||
ip, |
||||
t: IServerEventType.FAILED_LOGIN_ATTEMPT, |
||||
}).count(); |
||||
} |
||||
|
||||
countFailedAttemptsByUsername(username: string): Promise<number> { |
||||
return this.col.find({ |
||||
'u.username': username, |
||||
t: IServerEventType.FAILED_LOGIN_ATTEMPT, |
||||
}).count(); |
||||
} |
||||
} |
@ -0,0 +1,5 @@ |
||||
export const addMinutesToADate = (date: Date, minutes: number): Date => { |
||||
const copy = new Date(date); |
||||
copy.setMinutes(copy.getMinutes() + minutes); |
||||
return copy; |
||||
}; |
@ -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?: Partial<IUser>; |
||||
} |
Loading…
Reference in new issue