diff --git a/app/controllers/account_controller.rb b/app/controllers/account_controller.rb
index dfe229526d..a19e8312bf 100644
--- a/app/controllers/account_controller.rb
+++ b/app/controllers/account_controller.rb
@@ -88,6 +88,7 @@ class AccountController < ApplicationController
@user.must_change_passwd = false
if @user.save
@token.destroy
+ @user.reset_failed_login_attempts!
Mailer.deliver_password_updated(@user, User.current)
flash[:notice] = l(:notice_account_password_updated)
redirect_to signin_path
@@ -186,6 +187,20 @@ class AccountController < ApplicationController
redirect_to signin_path
end
+ # Token based account unlock
+ def unlock
+ token = Token.find_token('unlock', params[:token].to_s)
+ (redirect_to(home_url); return) unless token and !token.expired?
+ user = token.user
+ (redirect_to(home_url); return) unless user.locked?
+ user.reset_failed_login_attempts!
+ if user.save
+ token.destroy
+ flash[:notice] = l(:notice_account_unlocked)
+ end
+ redirect_to signin_path
+ end
+
# Sends a new account activation email
def activation_email
if session[:registered_user_id] && Setting.self_registration == '1'
@@ -229,9 +244,15 @@ class AccountController < ApplicationController
# prevent brute force attacks on the one-time password
elsif session[:twofa_tries_counter] > 3
destroy_twofa_session
+ @user.add_failed_login_attempts!
+ account_locked(@user) and return if @user.locked?
+
flash[:error] = l('twofa_too_many_tries')
redirect_to home_url
else
+ @user.add_failed_login_attempts!
+ account_locked(@user) and return if @user.locked?
+
flash[:error] = l('twofa_invalid_code')
redirect_to account_twofa_confirm_path
end
@@ -330,6 +351,8 @@ class AccountController < ApplicationController
end
def successful_authentication(user)
+ user.reset_failed_login_attempts!
+
logger.info "Successful authentication for '#{user.login}' from #{request.remote_ip} at #{Time.now.utc}"
# Valid user
self.logged_user = user
@@ -366,6 +389,10 @@ class AccountController < ApplicationController
end
def invalid_credentials
+ user = User.find_by_login(params[:username].to_s.strip)
+ user.add_failed_login_attempts! if user
+ account_locked(user) and return if user && user.locked?
+
logger.warn "Failed login for '#{params[:username]}' from #{request.remote_ip} at #{Time.now.utc}"
flash.now[:error] = l(:notice_account_invalid_credentials)
end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 77ec1a6013..63105f4e55 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -168,6 +168,8 @@ class UsersController < ApplicationController
was_activated = (@user.status_change == [User::STATUS_REGISTERED, User::STATUS_ACTIVE])
# TODO: Similar to My#account
@user.pref.safe_attributes = params[:pref]
+ # Reset failed_login_attempts if changed from other status to active
+ @user.failed_login_attempts = 0 if @user.will_save_change_to_status? && @user.active?
if @user.save
@user.pref.save
diff --git a/app/models/mailer.rb b/app/models/mailer.rb
index 558c280ec8..a326ce640c 100644
--- a/app/models/mailer.rb
+++ b/app/models/mailer.rb
@@ -459,6 +459,22 @@ class Mailer < ActionMailer::Base
register(user, token).deliver_later
end
+ # Builds a mail to user with account unlock link.
+ def locked(user, token)
+ @token = token
+ @url = url_for(controller: 'account', action: 'unlock', token: token.value)
+ mail :to => user.mail,
+ :subject => l(:mail_subject_locked, Setting.app_title)
+ end
+
+ # Sends an mail to user with account unlock link.
+ #
+ # Exemple:
+ # Mailer.deliver_locked(user, token)
+ def self.deliver_locked(user, token)
+ locked(user, token).deliver_later
+ end
+
# Build a mail to user and the additional recipients given in
# options[:recipients] about a security related event made by sender.
#
diff --git a/app/models/token.rb b/app/models/token.rb
index 1fd9c228b1..e8ae62c6ba 100644
--- a/app/models/token.rb
+++ b/app/models/token.rb
@@ -41,6 +41,7 @@ class Token < ActiveRecord::Base
add_action :feeds, max_instances: 1, validity_time: nil
add_action :recovery, max_instances: 1, validity_time: Proc.new {Token.validity_time}
add_action :register, max_instances: 1, validity_time: Proc.new {Token.validity_time}
+ add_action :unlock, max_instances: 1, validity_time: Proc.new {Token.validity_time}
add_action :session, max_instances: 10, validity_time: nil
add_action :twofa_backup_code, max_instances: 10, validity_time: nil
diff --git a/app/models/user.rb b/app/models/user.rb
index 7180191d93..8bb555dd34 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -284,6 +284,7 @@ class User < Principal
end
def activate
+ self.failed_login_attempts = 0
self.status = STATUS_ACTIVE
end
@@ -296,6 +297,7 @@ class User < Principal
end
def activate!
+ self.failed_login_attempts = 0
update_attribute(:status, STATUS_ACTIVE)
end
@@ -897,6 +899,43 @@ class User < Principal
User.where("created_on < ? AND status = ?", Time.now - age, STATUS_REGISTERED).destroy_all
end
+ def reset_failed_login_attempts!
+ return if failed_login_attempts.zero? && self.active?
+
+ self.failed_login_attempts = 0
+ self.activate if self.locked?
+ self.save!
+ end
+
+ def add_failed_login_attempts!
+ self.failed_login_attempts = self.failed_login_attempts + 1
+
+ if Setting.max_login_attempts.present? && self.failed_login_attempts > Setting.max_login_attempts.to_i && self.active?
+ self.lock_by_failed_login_attempts!
+ else
+ self.save!
+ end
+ end
+
+ # Execute in the event of consecutive failed login attempts.
+ # Lock user, create unlock token, send email to locked user and send security notification emails to administrators.
+ def lock_by_failed_login_attempts!
+ self.lock
+ token = Token.new(user: self, action: 'unlock')
+ self.save! and token.save!
+ Mailer.deliver_locked(self, token)
+
+ options = {
+ field: User.current.admin? ? :field_admin : :field_user,
+ value: login,
+ title: :label_user_plural,
+ url: {controller: 'users', action: 'index', status: STATUS_LOCKED},
+ message: :mail_body_security_notification_locked
+ }
+ users = User.active.where(admin: true).to_a
+ Mailer.deliver_security_notification(users, User.current, options)
+ end
+
protected
def validate_password_length
diff --git a/app/views/mailer/locked.html.erb b/app/views/mailer/locked.html.erb
new file mode 100644
index 0000000000..7f69490555
--- /dev/null
+++ b/app/views/mailer/locked.html.erb
@@ -0,0 +1,2 @@
+
<%= simple_format(l(:mail_body_locked)) %>
+<%= link_to @url, @url %>
diff --git a/app/views/mailer/locked.text.erb b/app/views/mailer/locked.text.erb
new file mode 100644
index 0000000000..b930a359a3
--- /dev/null
+++ b/app/views/mailer/locked.text.erb
@@ -0,0 +1,2 @@
+<%= l(:mail_body_locked) %>
+<%= @url %>
diff --git a/app/views/settings/_authentication.html.erb b/app/views/settings/_authentication.html.erb
index fc20dd03db..cf034fce3a 100644
--- a/app/views/settings/_authentication.html.erb
+++ b/app/views/settings/_authentication.html.erb
@@ -41,6 +41,9 @@
+
+ <%= setting_text_field :max_login_attempts, :size => 6 %>
+