diff --git a/.env.development b/.env.development index aca2bf304d6..9bdfa2687ff 100644 --- a/.env.development +++ b/.env.development @@ -10,3 +10,4 @@ SMTP_DOMAIN=localhost WELCOME_EMAIL_SENDER_NAME=dev WELCOME_EMAIL_SENDER_EMAIL=dev@dev.com DISABLED_PLUGINS="" +MAX_LOGIN_ATTEMPTS=3 diff --git a/.env.test b/.env.test index eb1c61ee9e7..01e80b79008 100644 --- a/.env.test +++ b/.env.test @@ -11,3 +11,4 @@ PRIVACY_URL="https://www.loomio.org/privacy" WELCOME_EMAIL_SENDER_NAME=dev WELCOME_EMAIL_SENDER_EMAIL=dev@dev.com DISABLED_PLUGINS="" +MAX_LOGIN_ATTEMPTS=3 diff --git a/app/controllers/api/sessions_controller.rb b/app/controllers/api/sessions_controller.rb index d25e9cfbbc0..20015a83e1a 100644 --- a/app/controllers/api/sessions_controller.rb +++ b/app/controllers/api/sessions_controller.rb @@ -10,7 +10,7 @@ def create user.update(name: resource_params[:name]) if resource_params[:name] render json: Boot::User.new(user).payload else - render json: { errors: { password: [t(:"user.error.bad_login")] } }, status: 401 + render json: { errors: failure_message }, status: 401 end session.delete(:pending_login_token) end @@ -24,6 +24,14 @@ def destroy private + def failure_message + if resource_params[:password] && User.where(email: resource_params[:email]).where.not(locked_at: nil).exists? + { password: [:'auth_form.account_locked'] } + else + { password: [:'auth_form.invalid_password'] } + end + end + def attempt_login if pending_login_token&.useable? pending_login_token.user diff --git a/app/models/user.rb b/app/models/user.rb index 5376e7719d7..1a7c370f629 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -23,7 +23,7 @@ class User < ApplicationRecord demo_bot: ENV['DEMO_BOT_EMAIL'] || 'contact+demo@loomio.org' }.freeze - devise :database_authenticatable, :recoverable, :registerable, :rememberable + devise :database_authenticatable, :recoverable, :registerable, :rememberable, :lockable attr_accessor :recaptcha attr_accessor :restricted attr_accessor :token diff --git a/client/angular/components/auth/signin_form/auth_signin_form.coffee b/client/angular/components/auth/signin_form/auth_signin_form.coffee index a3cc4ccdfd6..ad21ca7ad80 100644 --- a/client/angular/components/auth/signin_form/auth_signin_form.coffee +++ b/client/angular/components/auth/signin_form/auth_signin_form.coffee @@ -14,8 +14,10 @@ angular.module('loomioApp').directive 'authSigninForm', -> $scope.signIn = -> EventBus.emit $scope, 'processing' $scope.user.name = $scope.vars.name if $scope.vars.name? - AuthService.signIn($scope.user, hardReload).finally -> - EventBus.emit $scope, 'doneProcessing' + finished = -> + EventBus.emit $scope, 'doneProcessing'; + $scope.$apply(); + AuthService.signIn($scope.user, hardReload, finished).finally finished $scope.signInAndSetPassword = -> LmoUrlService.params('set_password', true) diff --git a/client/shared/services/auth_service.coffee b/client/shared/services/auth_service.coffee index e3199fb36bd..98f7159acb7 100644 --- a/client/shared/services/auth_service.coffee +++ b/client/shared/services/auth_service.coffee @@ -17,16 +17,18 @@ module.exports = new class AuthService user.update(hasToken: data.has_token) user - signIn: (user = {}, onSuccess) -> + signIn: (user = {}, onSuccess, finished) -> Records.sessions.build( _.pick(user, ['email', 'name', 'password']) ).save().then -> onSuccess() - , () -> - user.errors = if user.hasToken - { token: [I18n.t('auth_form.invalid_token')] } - else - { password: [I18n.t('auth_form.invalid_password')] } + , (response) -> + response.json().then (data) -> + user.errors = if user.hasToken + { token: [I18n.t('auth_form.invalid_token')] } + else + { password: _.map(data.errors.password, (key) -> I18n.t(key)) } + finished() signUp: (user, onSuccess) -> Records.registrations.build( diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index d1d7676163a..03ef6a3d0c5 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -9,7 +9,7 @@ # Configure the class responsible to send e-mails. # config.mailer = "Devise::Mailer" config.parent_mailer = "BaseMailer" - + Devise::Mailer.layout "invite_people_mailer" # Automatically apply schema changes in tableless databases # ==> ORM configuration @@ -136,24 +136,24 @@ # Defines which strategy will be used to lock an account. # :failed_attempts = Locks an account after a number of failed attempts to sign in. # :none = No lock strategy. You should handle locking by yourself. - # config.lock_strategy = :failed_attempts + config.lock_strategy = :failed_attempts # Defines which key will be used when locking and unlocking an account - # config.unlock_keys = [ :email ] + config.unlock_keys = [ :email ] # Defines which strategy will be used to unlock an account. # :email = Sends an unlock link to the user email # :time = Re-enables login after a certain amount of time (see :unlock_in below) # :both = Enables both strategies # :none = No unlock strategy. You should handle unlocking by yourself. - # config.unlock_strategy = :both + config.unlock_strategy = :both # Number of authentication tries before locking an account if lock_strategy # is failed attempts. - # config.maximum_attempts = 20 + config.maximum_attempts = ENV.fetch('MAX_LOGIN_ATTEMPTS', 20).to_i # Time interval to unlock the account if :time is enabled as unlock_strategy. - # config.unlock_in = 1.hour + config.unlock_in = 6.hours # ==> Configuration for :recoverable # diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 04e892cd3ba..5ed663bf8df 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1798,6 +1798,7 @@ en: invalid_email: "Sorry, that doesn't look like a valid email address" invalid_password: "Sorry, that password doesn't match the one we have on file" invalid_token: "Sorry, we weren't able to use that token to log you in. Click below to send another one." + account_locked: "Your account has been locked after too many failed login attempts. Check your email for an unlock link" email_not_found: "Sorry, we couldn't find an account with that email address" email_placeholder: you@example.com login_with_token: You're all set! Click the link below to continue. diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 1e5342db6f0..a1140ad3d20 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -22,7 +22,7 @@ en: placeholder_name: "Invited user (%{hostname})" error: recaptcha: "We weren't able to verify that you're not a robot. Please try again!" - bad_login: "Unable to log you in. Please try again." + bad_login: "Sorry, that password doesn't match the one we have on file" username_must_be_alphanumeric: "Usernames must be lower case letters and numbers only" attachment: diff --git a/db/migrate/20190310194641_add_lockable_to_devise.rb b/db/migrate/20190310194641_add_lockable_to_devise.rb new file mode 100644 index 00000000000..50cde05a0bf --- /dev/null +++ b/db/migrate/20190310194641_add_lockable_to_devise.rb @@ -0,0 +1,8 @@ +class AddLockableToDevise < ActiveRecord::Migration[5.2] + def change + add_column :users, :failed_attempts, :integer, default: 0, null: false # Only if lock strategy is :failed_attempts + add_column :users, :unlock_token, :string # Only if unlock strategy is :email or :both + add_column :users, :locked_at, :datetime + add_index :users, :unlock_token, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index d4d6eacf9b4..72e890ad6e7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_03_06_010911) do +ActiveRecord::Schema.define(version: 2019_03_10_194641) do # These are extensions that must be enabled in order to support this database enable_extension "citext" @@ -604,7 +604,6 @@ t.integer "max_members" t.integer "max_orgs" t.string "state", default: "active", null: false - t.index ["chargify_subscription_id"], name: "index_subscriptions_on_chargify_subscription_id", unique: true t.index ["owner_id"], name: "index_subscriptions_on_owner_id" end @@ -696,11 +695,15 @@ t.datetime "last_seen_at" t.datetime "legal_accepted_at" t.boolean "email_newsletter", default: false, null: false + t.integer "failed_attempts", default: 0, null: false + t.string "unlock_token" + t.datetime "locked_at" t.index ["deactivated_at"], name: "index_users_on_deactivated_at" t.index ["email"], name: "index_users_on_email", unique: true t.index ["email_verified"], name: "index_users_on_email_verified" t.index ["key"], name: "index_users_on_key", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true + t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true t.index ["unsubscribe_token"], name: "index_users_on_unsubscribe_token", unique: true t.index ["username"], name: "index_users_on_username", unique: true end