From 2dee57fd43ebe1cf1f43fb0161cdd5f072eeaa63 Mon Sep 17 00:00:00 2001
From: Nils Adermann <naderman@naderman.de>
Date: Fri, 10 Jun 2011 12:02:59 +0200
Subject: [PATCH] [ticket/9992] Adding a limit on login attempts per IP.

A new table was created to save all failed login attempts with
corresponding information on username, ip and useragent. By default
the limit is 50 login attempts within 6 hours per IP. The limit is
relatively high to avoid big problems on sites behind a reverse
proxy that don't receive the forwarded-for value as REMOTE_ADDR but
see all users as coming from the same IP address. But if these
users run into problems a special forwarded-for option is available
to limit logins by forwarded-for value instead of ip.

PHPBB3-9992
---
 phpBB/develop/create_schema_files.php     | 20 +++++++
 phpBB/includes/acp/acp_board.php          |  3 ++
 phpBB/includes/auth.php                   |  2 +-
 phpBB/includes/auth/auth_db.php           | 66 +++++++++++++++++++++--
 phpBB/includes/constants.php              |  1 +
 phpBB/includes/db/db_tools.php            | 13 +++++
 phpBB/includes/session.php                |  4 ++
 phpBB/install/database_update.php         | 39 +++++++++++++-
 phpBB/install/schemas/firebird_schema.sql | 32 ++++++++++-
 phpBB/install/schemas/mssql_schema.sql    | 38 ++++++++++++-
 phpBB/install/schemas/mysql_40_schema.sql | 20 ++++++-
 phpBB/install/schemas/mysql_41_schema.sql | 20 ++++++-
 phpBB/install/schemas/oracle_schema.sql   | 43 ++++++++++++++-
 phpBB/install/schemas/postgres_schema.sql | 24 ++++++++-
 phpBB/install/schemas/schema_data.sql     |  3 ++
 phpBB/install/schemas/sqlite_schema.sql   | 19 ++++++-
 phpBB/language/en/acp/board.php           | 12 +++--
 17 files changed, 343 insertions(+), 16 deletions(-)

diff --git a/phpBB/develop/create_schema_files.php b/phpBB/develop/create_schema_files.php
index 87670722aa..f1e2858848 100644
--- a/phpBB/develop/create_schema_files.php
+++ b/phpBB/develop/create_schema_files.php
@@ -1207,6 +1207,26 @@ function get_schema_struct()
 		),
 	);
 
+	$schema_data['phpbb_login_attempts'] = array(
+		'COLUMNS'		=> array(
+			'attempt_id'			=> array('UINT', NULL, 'auto_increment'),
+			'attempt_ip'			=> array('VCHAR:40', ''),
+			'attempt_browser'		=> array('VCHAR:150', ''),
+			'attempt_forwarded_for'	=> array('VCHAR:255', ''),
+			'attempt_time'			=> array('TIMESTAMP', 0),
+			'user_id'				=> array('UINT', 0),
+			'username'				=> array('VCHAR_UNI:255', 0),
+			'username_clean'		=> array('VCHAR_CI', 0),
+		),
+		'PRIMARY_KEY'	=> 'attempt_id',
+		'KEYS'			=> array(
+			'attempt_ip'				=> array('INDEX', array('attempt_ip', 'attempt_time')),
+			'attempt_forwarded_for'		=> array('INDEX', array('attempt_forwarded_for', 'attempt_time')),
+			'attempt_time'				=> array('INDEX', array('attempt_time')),
+			'user_id'					=> array('INDEX', 'user_id'),
+		),
+	);
+
 	$schema_data['phpbb_moderator_cache'] = array(
 		'COLUMNS'		=> array(
 			'forum_id'				=> array('UINT', 0),
diff --git a/phpBB/includes/acp/acp_board.php b/phpBB/includes/acp/acp_board.php
index d38c4d58ba..9f00145f3b 100644
--- a/phpBB/includes/acp/acp_board.php
+++ b/phpBB/includes/acp/acp_board.php
@@ -386,6 +386,9 @@ class acp_board
 						'pass_complex'			=> array('lang' => 'PASSWORD_TYPE',			'validate' => 'string',	'type' => 'select', 'method' => 'select_password_chars', 'explain' => true),
 						'chg_passforce'			=> array('lang' => 'FORCE_PASS_CHANGE',		'validate' => 'int:0',	'type' => 'text:3:3', 'explain' => true, 'append' => ' ' . $user->lang['DAYS']),
 						'max_login_attempts'	=> array('lang' => 'MAX_LOGIN_ATTEMPTS',	'validate' => 'int:0',	'type' => 'text:3:3', 'explain' => true),
+						'ip_login_limit_max'	=> array('lang' => 'IP_LOGIN_LIMIT_MAX',	'validate' => 'int:0',	'type' => 'text:3:3', 'explain' => true),
+						'ip_login_limit_time'	=> array('lang' => 'IP_LOGIN_LIMIT_TIME',	'validate' => 'int:0',	'type' => 'text:5:5', 'explain' => true),
+						'ip_login_limit_use_forwarded'	=> array('lang' => 'IP_LOGIN_LIMIT_USE_FORWARDED',	'validate' => 'bool',	'type' => 'radio:yes_no', 'explain' => true),
 						'tpl_allow_php'			=> array('lang' => 'TPL_ALLOW_PHP',			'validate' => 'bool',	'type' => 'radio:yes_no', 'explain' => true),
 						'form_token_lifetime'	=> array('lang' => 'FORM_TIME_MAX',			'validate' => 'int:-1',	'type' => 'text:5:5', 'explain' => true, 'append' => ' ' . $user->lang['SECONDS']),
 						'form_token_sid_guests'	=> array('lang' => 'FORM_SID_GUESTS',		'validate' => 'bool',	'type' => 'radio:yes_no', 'explain' => true),
diff --git a/phpBB/includes/auth.php b/phpBB/includes/auth.php
index 8324cb4977..5564de2943 100644
--- a/phpBB/includes/auth.php
+++ b/phpBB/includes/auth.php
@@ -908,7 +908,7 @@ class auth
 		$method = 'login_' . $method;
 		if (function_exists($method))
 		{
-			$login = $method($username, $password);
+			$login = $method($username, $password, $user->ip, $user->browser, $user->forwarded_for);
 
 			// If the auth module wants us to create an empty profile do so and then treat the status as LOGIN_SUCCESS
 			if ($login['status'] == LOGIN_SUCCESS_CREATE_PROFILE)
diff --git a/phpBB/includes/auth/auth_db.php b/phpBB/includes/auth/auth_db.php
index e04a6307e9..e155130e04 100644
--- a/phpBB/includes/auth/auth_db.php
+++ b/phpBB/includes/auth/auth_db.php
@@ -23,8 +23,21 @@ if (!defined('IN_PHPBB'))
 
 /**
 * Login function
+*
+* @param string $username
+* @param string $password
+* @param string $ip			IP address the login is taking place from. Used to
+*							limit the number of login attempts per IP address.
+* @param string $browser	The user agent used to login
+* @param string $forwarded_for X_FORWARDED_FOR header sent with login request
+* @return array				A associative array of the format
+*							array(
+*								'status' => status constant
+*								'error_msg' => string
+*								'user_row' => array
+*							)
 */
-function login_db(&$username, &$password)
+function login_db($username, $password, $ip = '', $browser = '', $forwarded_for = '')
 {
 	global $db, $config;
 
@@ -47,13 +60,52 @@ function login_db(&$username, &$password)
 		);
 	}
 
+	$username_clean = utf8_clean_string($username);
+
 	$sql = 'SELECT user_id, username, user_password, user_passchg, user_pass_convert, user_email, user_type, user_login_attempts
 		FROM ' . USERS_TABLE . "
-		WHERE username_clean = '" . $db->sql_escape(utf8_clean_string($username)) . "'";
+		WHERE username_clean = '" . $db->sql_escape($username_clean) . "'";
 	$result = $db->sql_query($sql);
 	$row = $db->sql_fetchrow($result);
 	$db->sql_freeresult($result);
 
+	if (($ip && !$config['ip_login_limit_use_forwarded']) ||
+		($forwarded_for && $config['ip_login_limit_use_forwarded']))
+	{
+		$sql = 'SELECT COUNT(attempt_id) AS count
+			FROM ' . LOGIN_ATTEMPT_TABLE . '
+			WHERE attempt_time > ' . (time() - (int) $config['ip_login_limit_time']);
+		if ($config['ip_login_limit_use_forwarded'])
+		{
+			$sql .= " AND attempt_forwarded_for = '" . $db->sql_escape($forwarded_for) . "'";
+		}
+		else
+		{
+			$sql .= " AND attempt_ip = '" . $db->sql_escape($ip) . "' ";
+		}
+
+		$result = $db->sql_query($sql);
+		$attempts_row = $db->sql_fetchrow($result);
+		$db->sql_freeresult($result);
+		$attempts = $attempts_row['count'];
+
+		$attempt_data = array(
+			'attempt_ip'			=> $ip,
+			'attempt_browser'		=> $browser,
+			'attempt_forwarded_for'	=> $forwarded_for,
+			'attempt_time'			=> time(),
+			'user_id'				=> ($row) ? (int) $row['user_id'] : 0,
+			'username'				=> $username,
+			'username_clean'		=> $username_clean,
+		);
+		$sql = 'INSERT INTO ' . LOGIN_ATTEMPT_TABLE . $db->sql_build_array('INSERT', $attempt_data);
+		$result = $db->sql_query($sql);
+	}
+	else
+	{
+		$attempts = 0;
+	}
+
 	if (!$row)
 	{
 		return array(
@@ -62,7 +114,9 @@ function login_db(&$username, &$password)
 			'user_row'	=> array('user_id' => ANONYMOUS),
 		);
 	}
-	$show_captcha = $config['max_login_attempts'] && $row['user_login_attempts'] >= $config['max_login_attempts'];
+
+	$show_captcha = ($config['max_login_attempts'] && $row['user_login_attempts'] >= $config['max_login_attempts']) ||
+		($config['ip_login_limit_max'] && $attempts >= $config['ip_login_limit_max']);
 
 	// If there are too much login attempts, we need to check for an confirm image
 	// Every auth module is able to define what to do by itself...
@@ -90,7 +144,7 @@ function login_db(&$username, &$password)
 		{
 			$captcha->reset();
 		}
-		
+
 	}
 
 	// If the password convert flag is set we need to convert it
@@ -165,6 +219,10 @@ function login_db(&$username, &$password)
 			$row['user_password'] = $hash;
 		}
 
+		$sql = 'DELETE FROM ' . LOGIN_ATTEMPT_TABLE . '
+			WHERE user_id = ' . $row['user_id'];
+		$db->sql_query($sql);
+
 		if ($row['user_login_attempts'] != 0)
 		{
 			// Successful, reset login attempts (the user passed all stages)
diff --git a/phpBB/includes/constants.php b/phpBB/includes/constants.php
index ea34eb8e81..b5a0aa893a 100644
--- a/phpBB/includes/constants.php
+++ b/phpBB/includes/constants.php
@@ -236,6 +236,7 @@ define('GROUPS_TABLE',				$table_prefix . 'groups');
 define('ICONS_TABLE',				$table_prefix . 'icons');
 define('LANG_TABLE',				$table_prefix . 'lang');
 define('LOG_TABLE',					$table_prefix . 'log');
+define('LOGIN_ATTEMPT_TABLE',		$table_prefix . 'login_attempts');
 define('MODERATOR_CACHE_TABLE',		$table_prefix . 'moderator_cache');
 define('MODULES_TABLE',				$table_prefix . 'modules');
 define('POLL_OPTIONS_TABLE',		$table_prefix . 'poll_options');
diff --git a/phpBB/includes/db/db_tools.php b/phpBB/includes/db/db_tools.php
index 483ceee043..fdefda9e26 100644
--- a/phpBB/includes/db/db_tools.php
+++ b/phpBB/includes/db/db_tools.php
@@ -638,6 +638,19 @@ class phpbb_db_tools
 			$sqlite = true;
 		}
 
+		// Add tables?
+		if (!empty($schema_changes['add_tables']))
+		{
+			foreach ($schema_changes['add_tables'] as $table => $table_data)
+			{
+				$result = $this->sql_create_table($table, $table_data);
+				if ($this->return_statements)
+				{
+					$statements = array_merge($statements, $result);
+				}
+			}
+		}
+
 		// Change columns?
 		if (!empty($schema_changes['change_columns']))
 		{
diff --git a/phpBB/includes/session.php b/phpBB/includes/session.php
index ceb22c197c..69369ff72d 100644
--- a/phpBB/includes/session.php
+++ b/phpBB/includes/session.php
@@ -1005,6 +1005,10 @@ class session
 				include($phpbb_root_path . "includes/captcha/captcha_factory." . $phpEx);
 			}
 			phpbb_captcha_factory::garbage_collect($config['captcha_plugin']);
+
+			$sql = 'DELETE FROM ' . LOGIN_ATTEMPT_TABLE . '
+				WHERE attempt_time < ' . (time() - (int) $config['ip_login_limit_time']);
+			$db->sql_query($sql);
 		}
 
 		return;
diff --git a/phpBB/install/database_update.php b/phpBB/install/database_update.php
index 77b5f44502..24a69ab99b 100644
--- a/phpBB/install/database_update.php
+++ b/phpBB/install/database_update.php
@@ -916,9 +916,29 @@ function database_update_info()
 		'3.0.7-PL1'		=> array(),
 		// No changes from 3.0.8-RC1 to 3.0.8
 		'3.0.8-RC1'		=> array(),
-
 		// Changes from 3.0.8 to 3.0.9-RC1
 		'3.0.8'			=> array(
+			'add_tables'		=> array(
+				LOGIN_ATTEMPT_TABLE	=> array(
+					'COLUMNS'			=> array(
+						'attempt_id'			=> array('UINT', NULL, 'auto_increment'),
+						'attempt_ip'			=> array('VCHAR:40', ''),
+						'attempt_browser'		=> array('VCHAR:150', ''),
+						'attempt_forwarded_for'	=> array('VCHAR:255', ''),
+						'attempt_time'			=> array('TIMESTAMP', 0),
+						'user_id'				=> array('UINT', 0),
+						'username'				=> array('VCHAR_UNI:255', 0),
+						'username_clean'		=> array('VCHAR_CI', 0),
+					),
+					'PRIMARY_KEY'		=> 'attempt_id',
+					'KEYS'				=> array(
+						'attempt_ip'			=> array('INDEX', array('attempt_ip', 'attempt_time')),
+						'attempt_forwarded_for'	=> array('INDEX', array('attempt_forwarded_for', 'attempt_time')),
+						'attempt_time'			=> array('INDEX', array('attempt_time')),
+						'user_id'				=> array('INDEX', 'user_id'),
+					),
+				),
+			),
 			'change_columns'	=> array(
 				BBCODES_TABLE	=> array(
 					'bbcode_id'	=> array('USINT', 0),
@@ -1870,6 +1890,10 @@ function change_database_data(&$no_updates, $version)
 
 		// Changes from 3.0.8 to 3.0.9-RC1
 		case '3.0.8':
+			set_config('ip_login_limit_max', '50');
+			set_config('ip_login_limit_time', '21600');
+			set_config('ip_login_limit_use_forwarded', '0');
+
 			// Update file extension group names to use language strings, again.
 			$sql = 'SELECT group_id, group_name
 				FROM ' . EXTENSION_GROUPS_TABLE . '
@@ -2516,6 +2540,19 @@ class updater_db_tools
 			$sqlite = true;
 		}
 
+		// Add tables?
+		if (!empty($schema_changes['add_tables']))
+		{
+			foreach ($schema_changes['add_tables'] as $table => $table_data)
+			{
+				$result = $this->sql_create_table($table, $table_data);
+				if ($this->return_statements)
+				{
+					$statements = array_merge($statements, $result);
+				}
+			}
+		}
+
 		// Change columns?
 		if (!empty($schema_changes['change_columns']))
 		{
diff --git a/phpBB/install/schemas/firebird_schema.sql b/phpBB/install/schemas/firebird_schema.sql
index 85f86781de..24ebd0f1af 100644
--- a/phpBB/install/schemas/firebird_schema.sql
+++ b/phpBB/install/schemas/firebird_schema.sql
@@ -1,5 +1,5 @@
 #
-# $Id$
+# $Id: $
 #
 
 
@@ -545,6 +545,36 @@ BEGIN
 END;;
 
 
+# Table: 'phpbb_login_attempts'
+CREATE TABLE phpbb_login_attempts (
+	attempt_id INTEGER NOT NULL,
+	attempt_ip VARCHAR(40) CHARACTER SET NONE DEFAULT '' NOT NULL,
+	attempt_browser VARCHAR(150) CHARACTER SET NONE DEFAULT '' NOT NULL,
+	attempt_forwarded_for VARCHAR(255) CHARACTER SET NONE DEFAULT '' NOT NULL,
+	attempt_time INTEGER DEFAULT 0 NOT NULL,
+	user_id INTEGER DEFAULT 0 NOT NULL,
+	username VARCHAR(255) CHARACTER SET UTF8 DEFAULT 0 NOT NULL COLLATE UNICODE,
+	username_clean VARCHAR(255) CHARACTER SET UTF8 DEFAULT 0 NOT NULL COLLATE UNICODE
+);;
+
+ALTER TABLE phpbb_login_attempts ADD PRIMARY KEY (attempt_id);;
+
+CREATE INDEX phpbb_login_attempts_attempt_ip ON phpbb_login_attempts(attempt_ip, attempt_time);;
+CREATE INDEX phpbb_login_attempts_attempt_forwarded_for ON phpbb_login_attempts(attempt_forwarded_for, attempt_time);;
+CREATE INDEX phpbb_login_attempts_attempt_time ON phpbb_login_attempts(attempt_time);;
+CREATE INDEX phpbb_login_attempts_user_id ON phpbb_login_attempts(user_id);;
+
+CREATE GENERATOR phpbb_login_attempts_gen;;
+SET GENERATOR phpbb_login_attempts_gen TO 0;;
+
+CREATE TRIGGER t_phpbb_login_attempts FOR phpbb_login_attempts
+BEFORE INSERT
+AS
+BEGIN
+	NEW.attempt_id = GEN_ID(phpbb_login_attempts_gen, 1);
+END;;
+
+
 # Table: 'phpbb_moderator_cache'
 CREATE TABLE phpbb_moderator_cache (
 	forum_id INTEGER DEFAULT 0 NOT NULL,
diff --git a/phpBB/install/schemas/mssql_schema.sql b/phpBB/install/schemas/mssql_schema.sql
index 0827b14cc2..dbbd144aa0 100644
--- a/phpBB/install/schemas/mssql_schema.sql
+++ b/phpBB/install/schemas/mssql_schema.sql
@@ -1,6 +1,6 @@
 /*
 
- $Id$
+ $Id: $
 
 */
 
@@ -649,6 +649,41 @@ CREATE  INDEX [user_id] ON [phpbb_log]([user_id]) ON [PRIMARY]
 GO
 
 
+/*
+	Table: 'phpbb_login_attempts'
+*/
+CREATE TABLE [phpbb_login_attempts] (
+	[attempt_id] [int] IDENTITY (1, 1) NOT NULL ,
+	[attempt_ip] [varchar] (40) DEFAULT ('') NOT NULL ,
+	[attempt_browser] [varchar] (150) DEFAULT ('') NOT NULL ,
+	[attempt_forwarded_for] [varchar] (255) DEFAULT ('') NOT NULL ,
+	[attempt_time] [int] DEFAULT (0) NOT NULL ,
+	[user_id] [int] DEFAULT (0) NOT NULL ,
+	[username] [varchar] (255) DEFAULT (0) NOT NULL ,
+	[username_clean] [varchar] (255) DEFAULT (0) NOT NULL 
+) ON [PRIMARY]
+GO
+
+ALTER TABLE [phpbb_login_attempts] WITH NOCHECK ADD 
+	CONSTRAINT [PK_phpbb_login_attempts] PRIMARY KEY  CLUSTERED 
+	(
+		[attempt_id]
+	)  ON [PRIMARY] 
+GO
+
+CREATE  INDEX [attempt_ip] ON [phpbb_login_attempts]([attempt_ip], [attempt_time]) ON [PRIMARY]
+GO
+
+CREATE  INDEX [attempt_forwarded_for] ON [phpbb_login_attempts]([attempt_forwarded_for], [attempt_time]) ON [PRIMARY]
+GO
+
+CREATE  INDEX [attempt_time] ON [phpbb_login_attempts]([attempt_time]) ON [PRIMARY]
+GO
+
+CREATE  INDEX [user_id] ON [phpbb_login_attempts]([user_id]) ON [PRIMARY]
+GO
+
+
 /*
 	Table: 'phpbb_moderator_cache'
 */
@@ -1730,3 +1765,4 @@ ALTER TABLE [phpbb_zebra] WITH NOCHECK ADD
 	)  ON [PRIMARY] 
 GO
 
+
diff --git a/phpBB/install/schemas/mysql_40_schema.sql b/phpBB/install/schemas/mysql_40_schema.sql
index eeaec4ccf6..bce04eedfb 100644
--- a/phpBB/install/schemas/mysql_40_schema.sql
+++ b/phpBB/install/schemas/mysql_40_schema.sql
@@ -1,5 +1,5 @@
 #
-# $Id$
+# $Id: $
 #
 
 # Table: 'phpbb_attachments'
@@ -369,6 +369,24 @@ CREATE TABLE phpbb_log (
 );
 
 
+# Table: 'phpbb_login_attempts'
+CREATE TABLE phpbb_login_attempts (
+	attempt_id mediumint(8) UNSIGNED NOT NULL auto_increment,
+	attempt_ip varbinary(40) DEFAULT '' NOT NULL,
+	attempt_browser varbinary(150) DEFAULT '' NOT NULL,
+	attempt_forwarded_for varbinary(255) DEFAULT '' NOT NULL,
+	attempt_time int(11) UNSIGNED DEFAULT '0' NOT NULL,
+	user_id mediumint(8) UNSIGNED DEFAULT '0' NOT NULL,
+	username blob NOT NULL,
+	username_clean blob NOT NULL,
+	PRIMARY KEY (attempt_id),
+	KEY attempt_ip (attempt_ip, attempt_time),
+	KEY attempt_forwarded_for (attempt_forwarded_for, attempt_time),
+	KEY attempt_time (attempt_time),
+	KEY user_id (user_id)
+);
+
+
 # Table: 'phpbb_moderator_cache'
 CREATE TABLE phpbb_moderator_cache (
 	forum_id mediumint(8) UNSIGNED DEFAULT '0' NOT NULL,
diff --git a/phpBB/install/schemas/mysql_41_schema.sql b/phpBB/install/schemas/mysql_41_schema.sql
index 3a3b4ab2fd..e77ad44dd8 100644
--- a/phpBB/install/schemas/mysql_41_schema.sql
+++ b/phpBB/install/schemas/mysql_41_schema.sql
@@ -1,5 +1,5 @@
 #
-# $Id$
+# $Id: $
 #
 
 # Table: 'phpbb_attachments'
@@ -369,6 +369,24 @@ CREATE TABLE phpbb_log (
 ) CHARACTER SET `utf8` COLLATE `utf8_bin`;
 
 
+# Table: 'phpbb_login_attempts'
+CREATE TABLE phpbb_login_attempts (
+	attempt_id mediumint(8) UNSIGNED NOT NULL auto_increment,
+	attempt_ip varchar(40) DEFAULT '' NOT NULL,
+	attempt_browser varchar(150) DEFAULT '' NOT NULL,
+	attempt_forwarded_for varchar(255) DEFAULT '' NOT NULL,
+	attempt_time int(11) UNSIGNED DEFAULT '0' NOT NULL,
+	user_id mediumint(8) UNSIGNED DEFAULT '0' NOT NULL,
+	username varchar(255) DEFAULT '0' NOT NULL,
+	username_clean varchar(255) DEFAULT '0' NOT NULL,
+	PRIMARY KEY (attempt_id),
+	KEY attempt_ip (attempt_ip, attempt_time),
+	KEY attempt_forwarded_for (attempt_forwarded_for, attempt_time),
+	KEY attempt_time (attempt_time),
+	KEY user_id (user_id)
+) CHARACTER SET `utf8` COLLATE `utf8_bin`;
+
+
 # Table: 'phpbb_moderator_cache'
 CREATE TABLE phpbb_moderator_cache (
 	forum_id mediumint(8) UNSIGNED DEFAULT '0' NOT NULL,
diff --git a/phpBB/install/schemas/oracle_schema.sql b/phpBB/install/schemas/oracle_schema.sql
index 9c25af2512..5a1e4930b2 100644
--- a/phpBB/install/schemas/oracle_schema.sql
+++ b/phpBB/install/schemas/oracle_schema.sql
@@ -1,6 +1,6 @@
 /*
 
- $Id$
+ $Id: $
 
 */
 
@@ -740,6 +740,47 @@ END;
 /
 
 
+/*
+	Table: 'phpbb_login_attempts'
+*/
+CREATE TABLE phpbb_login_attempts (
+	attempt_id number(8) NOT NULL,
+	attempt_ip varchar2(40) DEFAULT '' ,
+	attempt_browser varchar2(150) DEFAULT '' ,
+	attempt_forwarded_for varchar2(255) DEFAULT '' ,
+	attempt_time number(11) DEFAULT '0' NOT NULL,
+	user_id number(8) DEFAULT '0' NOT NULL,
+	username varchar2(765) DEFAULT '0' NOT NULL,
+	username_clean varchar2(255) DEFAULT '0' NOT NULL,
+	CONSTRAINT pk_phpbb_login_attempts PRIMARY KEY (attempt_id)
+)
+/
+
+CREATE INDEX phpbb_login_attempts_attempt_ip ON phpbb_login_attempts (attempt_ip, attempt_time)
+/
+CREATE INDEX phpbb_login_attempts_attempt_forwarded_for ON phpbb_login_attempts (attempt_forwarded_for, attempt_time)
+/
+CREATE INDEX phpbb_login_attempts_attempt_time ON phpbb_login_attempts (attempt_time)
+/
+CREATE INDEX phpbb_login_attempts_user_id ON phpbb_login_attempts (user_id)
+/
+
+CREATE SEQUENCE phpbb_login_attempts_seq
+/
+
+CREATE OR REPLACE TRIGGER t_phpbb_login_attempts
+BEFORE INSERT ON phpbb_login_attempts
+FOR EACH ROW WHEN (
+	new.attempt_id IS NULL OR new.attempt_id = 0
+)
+BEGIN
+	SELECT phpbb_login_attempts_seq.nextval
+	INTO :new.attempt_id
+	FROM dual;
+END;
+/
+
+
 /*
 	Table: 'phpbb_moderator_cache'
 */
diff --git a/phpBB/install/schemas/postgres_schema.sql b/phpBB/install/schemas/postgres_schema.sql
index a2d4dc3e0b..c4c2307c3a 100644
--- a/phpBB/install/schemas/postgres_schema.sql
+++ b/phpBB/install/schemas/postgres_schema.sql
@@ -1,6 +1,6 @@
 /*
 
- $Id$
+ $Id: $
 
 */
 
@@ -524,6 +524,28 @@ CREATE INDEX phpbb_log_topic_id ON phpbb_log (topic_id);
 CREATE INDEX phpbb_log_reportee_id ON phpbb_log (reportee_id);
 CREATE INDEX phpbb_log_user_id ON phpbb_log (user_id);
 
+/*
+	Table: 'phpbb_login_attempts'
+*/
+CREATE SEQUENCE phpbb_login_attempts_seq;
+
+CREATE TABLE phpbb_login_attempts (
+	attempt_id INT4 DEFAULT nextval('phpbb_login_attempts_seq'),
+	attempt_ip varchar(40) DEFAULT '' NOT NULL,
+	attempt_browser varchar(150) DEFAULT '' NOT NULL,
+	attempt_forwarded_for varchar(255) DEFAULT '' NOT NULL,
+	attempt_time INT4 DEFAULT '0' NOT NULL CHECK (attempt_time >= 0),
+	user_id INT4 DEFAULT '0' NOT NULL CHECK (user_id >= 0),
+	username varchar(255) DEFAULT '0' NOT NULL,
+	username_clean varchar_ci DEFAULT '0' NOT NULL,
+	PRIMARY KEY (attempt_id)
+);
+
+CREATE INDEX phpbb_login_attempts_attempt_ip ON phpbb_login_attempts (attempt_ip, attempt_time);
+CREATE INDEX phpbb_login_attempts_attempt_forwarded_for ON phpbb_login_attempts (attempt_forwarded_for, attempt_time);
+CREATE INDEX phpbb_login_attempts_attempt_time ON phpbb_login_attempts (attempt_time);
+CREATE INDEX phpbb_login_attempts_user_id ON phpbb_login_attempts (user_id);
+
 /*
 	Table: 'phpbb_moderator_cache'
 */
diff --git a/phpBB/install/schemas/schema_data.sql b/phpBB/install/schemas/schema_data.sql
index ea4157d6a3..08d6c18ee8 100644
--- a/phpBB/install/schemas/schema_data.sql
+++ b/phpBB/install/schemas/schema_data.sql
@@ -136,6 +136,9 @@ INSERT INTO phpbb_config (config_name, config_value) VALUES ('img_max_thumb_widt
 INSERT INTO phpbb_config (config_name, config_value) VALUES ('img_max_width', '0');
 INSERT INTO phpbb_config (config_name, config_value) VALUES ('img_min_thumb_filesize', '12000');
 INSERT INTO phpbb_config (config_name, config_value) VALUES ('ip_check', '3');
+INSERT INTO phpbb_config (config_name, config_value) VALUES ('ip_login_limit_max', '50');
+INSERT INTO phpbb_config (config_name, config_value) VALUES ('ip_login_limit_time', '21600');
+INSERT INTO phpbb_config (config_name, config_value) VALUES ('ip_login_limit_use_forwarded', '0');
 INSERT INTO phpbb_config (config_name, config_value) VALUES ('jab_enable', '0');
 INSERT INTO phpbb_config (config_name, config_value) VALUES ('jab_host', '');
 INSERT INTO phpbb_config (config_name, config_value) VALUES ('jab_password', '');
diff --git a/phpBB/install/schemas/sqlite_schema.sql b/phpBB/install/schemas/sqlite_schema.sql
index 8661bb7578..9668c8d5f1 100644
--- a/phpBB/install/schemas/sqlite_schema.sql
+++ b/phpBB/install/schemas/sqlite_schema.sql
@@ -1,5 +1,5 @@
 #
-# $Id$
+# $Id: $
 #
 
 BEGIN TRANSACTION;
@@ -357,6 +357,23 @@ CREATE INDEX phpbb_log_topic_id ON phpbb_log (topic_id);
 CREATE INDEX phpbb_log_reportee_id ON phpbb_log (reportee_id);
 CREATE INDEX phpbb_log_user_id ON phpbb_log (user_id);
 
+# Table: 'phpbb_login_attempts'
+CREATE TABLE phpbb_login_attempts (
+	attempt_id INTEGER PRIMARY KEY NOT NULL ,
+	attempt_ip varchar(40) NOT NULL DEFAULT '',
+	attempt_browser varchar(150) NOT NULL DEFAULT '',
+	attempt_forwarded_for varchar(255) NOT NULL DEFAULT '',
+	attempt_time INTEGER UNSIGNED NOT NULL DEFAULT '0',
+	user_id INTEGER UNSIGNED NOT NULL DEFAULT '0',
+	username varchar(255) NOT NULL DEFAULT '0',
+	username_clean varchar(255) NOT NULL DEFAULT '0'
+);
+
+CREATE INDEX phpbb_login_attempts_attempt_ip ON phpbb_login_attempts (attempt_ip, attempt_time);
+CREATE INDEX phpbb_login_attempts_attempt_forwarded_for ON phpbb_login_attempts (attempt_forwarded_for, attempt_time);
+CREATE INDEX phpbb_login_attempts_attempt_time ON phpbb_login_attempts (attempt_time);
+CREATE INDEX phpbb_login_attempts_user_id ON phpbb_login_attempts (user_id);
+
 # Table: 'phpbb_moderator_cache'
 CREATE TABLE phpbb_moderator_cache (
 	forum_id INTEGER UNSIGNED NOT NULL DEFAULT '0',
diff --git a/phpBB/language/en/acp/board.php b/phpBB/language/en/acp/board.php
index 3a63e72b8f..5d6930ce98 100644
--- a/phpBB/language/en/acp/board.php
+++ b/phpBB/language/en/acp/board.php
@@ -458,12 +458,18 @@ $lang = array_merge($lang, array(
 	'FORM_TIME_MAX_EXPLAIN'			=> 'The time a user has to submit a form. Use -1 to disable. Note that a form might become invalid if the session expires, regardless of this setting.',
 	'FORM_SID_GUESTS'				=> 'Tie forms to guest sessions',
 	'FORM_SID_GUESTS_EXPLAIN'		=> 'If enabled, the form token issued to guests will be session-exclusive. This can cause problems with some ISPs.',
-	'FORWARDED_FOR_VALID'			=> 'Validated <var>X_FORWARDED_FOR</var> header',
+	'FORWARDED_FOR_VALID'			=> 'Validate <var>X_FORWARDED_FOR</var> header',
 	'FORWARDED_FOR_VALID_EXPLAIN'	=> 'Sessions will only be continued if the sent <var>X_FORWARDED_FOR</var> header equals the one sent with the previous request. Bans will be checked against IPs in <var>X_FORWARDED_FOR</var> too.',
 	'IP_VALID'						=> 'Session IP validation',
 	'IP_VALID_EXPLAIN'				=> 'Determines how much of the users IP is used to validate a session; <samp>All</samp> compares the complete address, <samp>A.B.C</samp> the first x.x.x, <samp>A.B</samp> the first x.x, <samp>None</samp> disables checking. On IPv6 addresses <samp>A.B.C</samp> compares the first 4 blocks and <samp>A.B</samp> the first 3 blocks.',
-	'MAX_LOGIN_ATTEMPTS'			=> 'Maximum number of login attempts',
-	'MAX_LOGIN_ATTEMPTS_EXPLAIN'	=> 'After this number of failed logins the user needs to additionally solve the anti-spambot task.',
+	'IP_LOGIN_LIMIT_MAX'			=> 'Maximum number of login attempts per IP address',
+	'IP_LOGIN_LIMIT_MAX_EXPLAIN'	=> 'Once the count of failed logins from an IP address exceeds this limit any login from the IP address requires solving an anti-spambot task. Select 0 to disable the limit, so that no tasks need to be solved.',
+	'IP_LOGIN_LIMIT_TIME'			=> 'IP address login attempt expiration time',
+	'IP_LOGIN_LIMIT_TIME_EXPLAIN'	=> 'Login attempts expire after this period, in seconds.',
+	'IP_LOGIN_LIMIT_USE_FORWARDED'	=> 'Limit login attempts by <var>X_FORWARDED_FOR</var> header',
+	'IP_LOGIN_LIMIT_USE_FORWARDED_EXPLAIN'	=> 'Instead of limiting login attempts by IP address they are limited by <var>X_FORWARDED_FOR</var> values. <br /><em><strong>Warning:</strong> Only enable this if you are operating a proxy server that sets <var>X_FORWARDED_FOR</var> to trustworthy values.</em>',
+	'MAX_LOGIN_ATTEMPTS'			=> 'Maximum number of login attempts per username',
+	'MAX_LOGIN_ATTEMPTS_EXPLAIN'	=> 'Logging into a user account requires solving an anti-spambot task after the count of failed logins for the account exceeds this limit ',
 	'NO_IP_VALIDATION'				=> 'None',
 	'NO_REF_VALIDATION'				=> 'None',
 	'PASSWORD_TYPE'					=> 'Password complexity',