Skip to content

Commit cac71a9

Browse files
Respect invalid login attempts when evaluating 2FA codes
https://community.openproject.org/work_packages/73313
1 parent b8ba228 commit cac71a9

File tree

3 files changed

+95
-0
lines changed

3 files changed

+95
-0
lines changed

modules/two_factor_authentication/app/controllers/concerns/two_factor_authentication/backup_codes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ def verify_backup_code
2121
if result.success?
2222
complete_stage_redirect
2323
else
24+
@authenticated_user.log_failed_login
2425
fail_login(t("two_factor_authentication.error_invalid_backup_code"))
2526
end
2627
end

modules/two_factor_authentication/app/controllers/two_factor_authentication/authentication_controller.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class AuthenticationController < ApplicationController
88
include ::TwoFactorAuthentication::BackupCodes
99
# Webauthn relying party based on domain
1010
include ::TwoFactorAuthentication::WebauthnRelyingParty
11+
1112
# Include global layout helper
1213
layout "no_menu"
1314

@@ -24,9 +25,15 @@ class AuthenticationController < ApplicationController
2425
before_action :only_post, only: :confirm_otp
2526

2627
# Require authenticated user from the core to be present
28+
# rubocop:disable Rails/LexicallyScopedActionFilter
2729
before_action :require_authenticated_user,
2830
only: %i(request_otp enter_backup_code verify_backup_code confirm_otp retry webauthn_challenge)
2931

32+
# Prevent additional tries when login attempts exceeded
33+
before_action :check_brute_force_protection,
34+
only: %i[confirm_otp verify_backup_code]
35+
# rubocop:enable Rails/LexicallyScopedActionFilter
36+
3037
before_action :ensure_valid_configuration, only: [:request_otp]
3138

3239
##
@@ -179,6 +186,7 @@ def login_if_otp_token_valid(user:, otp_token:, webauthn_credential:)
179186
set_remember_token!
180187
complete_stage_redirect
181188
else
189+
user.log_failed_login
182190
fail_login(I18n.t(:notice_account_otp_invalid))
183191
end
184192
end
@@ -193,6 +201,17 @@ def only_post
193201
end
194202
end
195203

204+
##
205+
# Block 2FA submission if the user has exceeded the brute force attempt threshold
206+
def check_brute_force_protection
207+
return unless @authenticated_user
208+
209+
if @authenticated_user.failed_too_many_recent_login_attempts?
210+
flash[:error] = I18n.t(:notice_account_invalid_credentials_or_blocked)
211+
failure_stage_redirect
212+
end
213+
end
214+
196215
##
197216
# fail the login
198217
def fail_login(msg)

modules/two_factor_authentication/spec/controllers/two_factor_authentication/authentication_controller_spec.rb

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,79 @@
116116
expect(response.response_code).to eq(405)
117117
end
118118
end
119+
120+
describe "brute force protection",
121+
with_settings: {
122+
"plugin_openproject_two_factor_authentication" => { active_strategies: %i[developer totp] },
123+
brute_force_block_after_failed_logins: 5,
124+
brute_force_block_minutes: 30
125+
} do
126+
let!(:device) { create(:two_factor_authentication_device_totp, user:, channel: :totp) }
127+
128+
before do
129+
session[:authenticated_user_id] = user.id
130+
end
131+
132+
describe "POST confirm_otp with an invalid token" do
133+
before do
134+
post :confirm_otp, params: { otp: "000000" }
135+
end
136+
137+
it "increments the failed_login_count" do
138+
expect(user.reload.failed_login_count).to eq 1
139+
end
140+
141+
it "redirects to the stage failure path" do
142+
expect(response).to redirect_to stage_failure_path(stage: :two_factor_authentication)
143+
end
144+
end
145+
146+
describe "POST confirm_otp when the user is already brute-force-blocked" do
147+
before do
148+
user.update!(failed_login_count: 5, last_failed_login_on: Time.zone.now)
149+
post :confirm_otp, params: { otp: "000000" }
150+
end
151+
152+
it "redirects to the stage failure path" do
153+
expect(response).to redirect_to stage_failure_path(stage: :two_factor_authentication)
154+
end
155+
156+
it "sets the blocked error message" do
157+
expect(flash[:error]).to eq I18n.t(:notice_account_invalid_credentials_or_blocked)
158+
end
159+
160+
it "does not increment failed_login_count further" do
161+
expect(user.reload.failed_login_count).to eq 5
162+
end
163+
end
164+
165+
describe "POST verify_backup_code with an invalid code" do
166+
before do
167+
post :verify_backup_code, params: { backup_code: "invalid-backup-code" }
168+
end
169+
170+
it "increments the failed_login_count" do
171+
expect(user.reload.failed_login_count).to eq 1
172+
end
173+
174+
it "redirects to the stage failure path" do
175+
expect(response).to redirect_to stage_failure_path(stage: :two_factor_authentication)
176+
end
177+
end
178+
179+
describe "POST verify_backup_code when the user is already brute-force-blocked" do
180+
before do
181+
user.update!(failed_login_count: 5, last_failed_login_on: Time.zone.now)
182+
post :verify_backup_code, params: { backup_code: "any-code" }
183+
end
184+
185+
it "redirects to the stage failure path" do
186+
expect(response).to redirect_to stage_failure_path(stage: :two_factor_authentication)
187+
end
188+
189+
it "sets the blocked error message" do
190+
expect(flash[:error]).to eq I18n.t(:notice_account_invalid_credentials_or_blocked)
191+
end
192+
end
193+
end
119194
end

0 commit comments

Comments
 (0)