Index: /home/amurmann/workspace/redmine/app/controllers/account_controller.rb
===================================================================
--- /home/amurmann/workspace/redmine/app/controllers/account_controller.rb	(revision 2648)
+++ /home/amurmann/workspace/redmine/app/controllers/account_controller.rb	(working copy)
@@ -141,8 +141,9 @@
     token = Token.find_by_action_and_value('register', params[:token])
     redirect_to(home_url) && return unless token and !token.expired?
     user = token.user
-    redirect_to(home_url) && return unless user.status == User::STATUS_REGISTERED
+    redirect_to(home_url) && return unless user.status == User::STATUS_REGISTERED || user.status == User::STATUS_LOCKED
     user.status = User::STATUS_ACTIVE
+    user.failed_logins = 0
     if user.save
       token.destroy
       flash[:notice] = l(:notice_account_activated)
@@ -155,8 +156,8 @@
   def password_authentication
     user = User.try_to_login(params[:username], params[:password])
     if user.nil?
-      # Invalid credentials
-      flash.now[:error] = l(:notice_account_invalid_creditentials)
+      # Invalid credentials      
+      flash.now[:error] = remaining_login_attempts_notice params[:username]
     elsif user.new_record?
       # Onthefly creation failed, display the registration form to fill/fix attributes
       @user = user
@@ -210,9 +211,12 @@
     end
   end
   
-  def successful_authentication(user)
+  def successful_authentication(user)    
     # Valid user
     self.logged_user = user
+    # Reset count for failed authentication attempts
+    user.failed_logins = 0
+    user.save
     # generate a key and set cookie if autologin
     if params[:autologin] && Setting.autologin?
       token = Token.create(:user => user, :action => 'autologin')
@@ -276,4 +280,19 @@
     flash[:notice] = l(:notice_account_pending)
     redirect_to :action => 'login'
   end
+  
+  def remaining_login_attempts_notice(user_name)
+      user = User.find_by_login(user_name)
+      allowed_attempts = Setting.allowed_login_attempts.to_i
+      return l(:notice_account_invalid_creditentials) if user.nil? || allowed_attempts == 0
+      if allowed_attempts - user.failed_logins > 0
+        s = l(:notice_account_invalid_creditentials)
+        s << " "
+        s << l(:notice_remaining_login_attempts, :value => (allowed_attempts - user.failed_logins))
+      else
+        s = l(:notice_too_many_login_attempts)
+      end
+    s 
+  end
+  
 end
Index: /home/amurmann/workspace/redmine/app/models/mailer.rb
===================================================================
--- /home/amurmann/workspace/redmine/app/models/mailer.rb	(revision 2648)
+++ /home/amurmann/workspace/redmine/app/models/mailer.rb	(working copy)
@@ -215,7 +215,21 @@
     subject 'Redmine test'
     body :url => url_for(:controller => 'welcome')
   end
-
+  
+  def account_locked(token, user)
+    set_language_if_valid(user.language)
+    recipients user.mail
+    subject l(:mail_subject_account_locked, :value => Setting.app_title)
+    body :project => Setting.app_title, :token => token,
+         :url => url_for(:controller => 'account', :action => 'activate', :token => token.value)
+  end
+    
+  def admin_info_locked_account(user, admin_mail)
+    recipients admin_mail
+    subject l(:mail_subject_admin_info_locked_account, :value => user.login)
+    body :value => user.login
+  end
+  
   # Overrides default deliver! method to prevent from sending an email
   # with no recipient, cc or bcc
   def deliver!(mail = @mail)
Index: /home/amurmann/workspace/redmine/app/models/user.rb
===================================================================
--- /home/amurmann/workspace/redmine/app/models/user.rb	(revision 2648)
+++ /home/amurmann/workspace/redmine/app/models/user.rb	(working copy)
@@ -50,7 +50,7 @@
   attr_accessor :password, :password_confirmation
   attr_accessor :last_before_login_on
   # Prevents unauthorized assignments
-  attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
+  attr_protected :login, :admin, :password, :password_confirmation, :hashed_password, :failed_logins
 	
   validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
   validates_uniqueness_of :login, :if => Proc.new { |user| !user.login.blank? }
@@ -101,12 +101,18 @@
     if user
       # user is already in local database
       return nil if !user.active?
-      if user.auth_source
+      if user.auth_source        
         # user has an external authentication method
-        return nil unless user.auth_source.authenticate(login, password)
+        if !user.auth_source.authenticate(login, password)        
+          user.authentication_failed user
+          return nil
+        end
       else
         # authentication with local password
-        return nil unless User.hash_password(password) == user.hashed_password        
+        if User.hash_password(password) != user.hashed_password
+          user.authentication_failed
+          return nil
+        end       
       end
     else
       # user is not yet registered, try to authenticate with available sources
@@ -127,6 +133,21 @@
     raise text
   end
   
+  def authentication_failed
+    self.failed_logins = self.failed_logins + 1
+    if self.failed_logins >= Setting.allowed_login_attempts.to_i && Setting.allowed_login_attempts != 0
+      self.status = 3
+      token = Token.new(:user => self, :action => "register")
+      token.save     
+      Mailer.deliver_account_locked(token, self)
+      if !Setting.too_many_logins_email.empty?
+        logger.info("Sending admin mail.")
+        Mailer.deliver_admin_info_locked_account(self, Setting.too_many_logins_email)
+      end
+    end    
+    self.save
+  end
+  
   # Returns the user who matches the given autologin +key+ or nil
   def self.try_to_autologin(key)
     token = Token.find_by_action_and_value('autologin', key)
Index: /home/amurmann/workspace/redmine/app/views/mailer/account_locked.text.html.rhtml
===================================================================
--- /home/amurmann/workspace/redmine/app/views/mailer/account_locked.text.html.rhtml	(revision 0)
+++ /home/amurmann/workspace/redmine/app/views/mailer/account_locked.text.html.rhtml	(revision 0)
@@ -0,0 +1,2 @@
+<p><%= l(:mail_body_account_locked, :value => @project) %></p>
+<%= auto_link(@url) %></p>
Index: /home/amurmann/workspace/redmine/app/views/mailer/account_locked.text.plain.rhtml
===================================================================
--- /home/amurmann/workspace/redmine/app/views/mailer/account_locked.text.plain.rhtml	(revision 0)
+++ /home/amurmann/workspace/redmine/app/views/mailer/account_locked.text.plain.rhtml	(revision 0)
@@ -0,0 +1,2 @@
+<%= l(:mail_body_account_locked, :value => @project) %>
+<%= auto_link(@url) %>
Index: /home/amurmann/workspace/redmine/app/views/mailer/admin_info_locked_account.html.rhtml
===================================================================
--- /home/amurmann/workspace/redmine/app/views/mailer/admin_info_locked_account.html.rhtml	(revision 0)
+++ /home/amurmann/workspace/redmine/app/views/mailer/admin_info_locked_account.html.rhtml	(revision 0)
@@ -0,0 +1 @@
+<p><%= l(:mail_body_admin_info_locked_account, :value => @value) %></p>
Index: /home/amurmann/workspace/redmine/app/views/mailer/admin_info_locked_account.plain.rhtml
===================================================================
--- /home/amurmann/workspace/redmine/app/views/mailer/admin_info_locked_account.plain.rhtml	(revision 0)
+++ /home/amurmann/workspace/redmine/app/views/mailer/admin_info_locked_account.plain.rhtml	(revision 0)
@@ -0,0 +1 @@
+<%= l(:mail_body_admin_info_locked_account, :value => @value) %>
Index: /home/amurmann/workspace/redmine/app/views/settings/_authentication.rhtml
===================================================================
--- /home/amurmann/workspace/redmine/app/views/settings/_authentication.rhtml	(revision 2648)
+++ /home/amurmann/workspace/redmine/app/views/settings/_authentication.rhtml	(working copy)
@@ -20,6 +20,12 @@
 
 <p><label><%= l(:setting_openid) %></label>
 <%= check_box_tag 'settings[openid]', 1, Setting.openid?, :disabled => !Object.const_defined?(:OpenID) %><%= hidden_field_tag 'settings[openid]', 0 %></p>
+
+<p><label><%= l(:label_allowed_login_attempts) %></label>
+<%= text_field_tag 'settings[allowed_login_attempts]', Setting.allowed_login_attempts, :size => 6%><br /><em><%= l(:text_login_attempts) %></em></p>
+
+<p><label><%= l(:label_too_many_logins_email) %></label>
+<%= text_field_tag 'settings[too_many_logins_email]', Setting.too_many_logins_email, :size => 60%><br /><em><%= l(:text_account_locked_notification_address) %></em></p>
 </div>
 
 <div style="float:right;">
Index: /home/amurmann/workspace/redmine/config/locales/en.yml
===================================================================
--- /home/amurmann/workspace/redmine/config/locales/en.yml	(revision 2648)
+++ /home/amurmann/workspace/redmine/config/locales/en.yml	(working copy)
@@ -107,7 +107,7 @@
   general_first_day_of_week: '7'
   
   notice_account_updated: Account was successfully updated.
-  notice_account_invalid_creditentials: Invalid user or password
+  notice_account_invalid_creditentials: Invalid user or password.
   notice_account_password_updated: Password was successfully updated.
   notice_account_wrong_password: Wrong password
   notice_account_register_done: Account was successfully created. To activate your account, click on the link that was emailed to you.
@@ -130,6 +130,8 @@
   notice_account_pending: "Your account was created and is now pending administrator approval."
   notice_default_data_loaded: Default configuration successfully loaded.
   notice_unable_delete_version: Unable to delete version.
+  notice_remaining_login_attempts: "{{value}} login attempt(s) left."
+  notice_too_many_login_attempts: "Too many login attempts. Your account has been locked and an administrator has been informed."
   
   error_can_t_load_default_data: "Default configuration could not be loaded: {{value}}"
   error_scm_not_found: "The entry or revision was not found in the repository."
@@ -149,6 +151,10 @@
   mail_body_account_activation_request: "A new user ({{value}}) has registered. The account is pending your approval:"
   mail_subject_reminder: "{{count}} issue(s) due in the next days"
   mail_body_reminder: "{{count}} issue(s) that are assigned to you are due in the next {{days}} days:"
+  mail_subject_account_locked: "Your {{value}} account has been locked"
+  mail_body_account_locked: "You or someone else attempted too often to log in to your {{value}} account. Your account has been locked to protect you and your project. To reactivate your account, click on the following link: "
+  mail_body_admin_info_locked_account: "Due to too many failed login attempts, the account {{value}} has been locked."
+  mail_subject_admin_info_locked_account: "Account {{value}} locked."
   
   gui_validation_error: 1 error
   gui_validation_error_plural: "{{count}} errors"
@@ -668,6 +674,8 @@
   label_ascending: Ascending
   label_descending: Descending
   label_date_from_to: From {{start}} to {{end}}
+  label_allowed_login_attempts: Allowed login attempts
+  label_too_many_logins_email: Notify admin if locked
   
   button_login: Login
   button_submit: Submit
@@ -761,6 +769,8 @@
   text_repository_usernames_mapping: "Select or update the Redmine user mapped to each username found in the repository log.\nUsers with the same Redmine and repository username or email are automatically mapped."
   text_diff_truncated: '... This diff was truncated because it exceeds the maximum size that can be displayed.'
   text_custom_field_possible_values_info: 'One line for each value'
+  text_login_attempts: 'Put 0 to allow unlimited login attempts.'
+  text_account_locked_notification_address: "Set an administrator's email address to be notified if an account was locked because of too many login attempts.\nLeave this empty to notify noone."
   
   default_role_manager: Manager
   default_role_developper: Developer
Index: /home/amurmann/workspace/redmine/config/settings.yml
===================================================================
--- /home/amurmann/workspace/redmine/config/settings.yml	(revision 2648)
+++ /home/amurmann/workspace/redmine/config/settings.yml	(working copy)
@@ -150,3 +150,7 @@
   default: 0
 openid:
   default: 0
+allowed_login_attempts:
+  default: 0
+too_many_logins_email:
+  default: ''
Index: /home/amurmann/workspace/redmine/db/migrate/20090405025700_add_failed_logins_to_users.rb
===================================================================
--- /home/amurmann/workspace/redmine/db/migrate/20090405025700_add_failed_logins_to_users.rb	(revision 0)
+++ /home/amurmann/workspace/redmine/db/migrate/20090405025700_add_failed_logins_to_users.rb	(revision 0)
@@ -0,0 +1,9 @@
+class AddFailedLoginsToUsers < ActiveRecord::Migration
+  def self.up
+    add_column :users, :failed_logins, :integer, :default => 0
+  end
+
+  def self.down
+    remove_column :users, :failed_logins
+  end
+end
Index: /home/amurmann/workspace/redmine/test/unit/user_test.rb
===================================================================
--- /home/amurmann/workspace/redmine/test/unit/user_test.rb	(revision 2648)
+++ /home/amurmann/workspace/redmine/test/unit/user_test.rb	(working copy)
@@ -95,6 +95,19 @@
     assert_equal User.hash_password("hello"), user.hashed_password    
   end
   
+  def test_login_attempts
+    Setting.allowed_login_attempts = 2
+    user = User.try_to_login("admin", "wrong")
+    assert_equal nil, user
+    user = User.try_to_login("admin", "admin")
+    assert_equal "admin", user.login
+    
+    user = User.try_to_login("admin", "wrong")
+    user = User.try_to_login("admin", "wrong")
+    user = User.try_to_login("admin", "admin")
+    assert_equal nil, user   
+  end
+  
   def test_name_format
     assert_equal 'Smith, John', @jsmith.name(:lastname_coma_firstname)
     Setting.user_format = :firstname_lastname
