|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +require "rails_helper" |
| 4 | + |
| 5 | +# The re-authentication gate (ApplicationController#require_current_password!) |
| 6 | +# routes failures through Devise's valid_for_authentication?, so a wrong |
| 7 | +# current_password counts toward :lockable. Without that it would be an |
| 8 | +# unthrottled password oracle, usable even while the account is locked out of |
| 9 | +# sign-in. Exercised here via POST /user_roles; the gate is shared with |
| 10 | +# UsersController#update. |
| 11 | +RSpec.describe "Current password gate throttling", type: :request do |
| 12 | + let(:password) { "SecurePassword123!" } |
| 13 | + let(:admin) { FactoryBot.create(:user, :admin, password:, password_confirmation: password) } |
| 14 | + let(:target) { FactoryBot.create(:user) } |
| 15 | + let(:role) { FactoryBot.create(:role, :contributor) } |
| 16 | + |
| 17 | + def sign_in_headers |
| 18 | + allow(Rails.application.config).to receive(:enable_mfa).and_return(false) |
| 19 | + post "/auth/sign_in", params: {email: admin.email, password:}, as: :json |
| 20 | + expect(response).to have_http_status(:success) |
| 21 | + { |
| 22 | + "access-token" => response.headers["access-token"], |
| 23 | + "client" => response.headers["client"], |
| 24 | + "uid" => response.headers["uid"] |
| 25 | + } |
| 26 | + end |
| 27 | + |
| 28 | + # Signed in once, then reused: a fresh sign-in would reset failed_attempts |
| 29 | + # via Warden's after_set_user hook and mask the counting under test. |
| 30 | + let!(:headers) { sign_in_headers } |
| 31 | + |
| 32 | + def attempt(current_password) |
| 33 | + post "/user_roles", |
| 34 | + params: {current_password:, user_role: {user_id: target.id, role_id: role.id}}, |
| 35 | + headers:, as: :json |
| 36 | + end |
| 37 | + |
| 38 | + it "counts a wrong current_password toward lockout" do |
| 39 | + attempt("WrongPassword999!") |
| 40 | + |
| 41 | + expect(response).to have_http_status(401) |
| 42 | + expect(admin.reload.failed_attempts).to eq(1) |
| 43 | + end |
| 44 | + |
| 45 | + it "rejects a wrong current_password without locking below the threshold" do |
| 46 | + (Devise.maximum_attempts - 1).times { attempt("WrongPassword999!") } |
| 47 | + |
| 48 | + expect(response).to have_http_status(401) |
| 49 | + expect(admin.reload.failed_attempts).to eq(Devise.maximum_attempts - 1) |
| 50 | + expect(admin.reload).not_to be_access_locked |
| 51 | + end |
| 52 | + |
| 53 | + it "locks the account after maximum_attempts" do |
| 54 | + Devise.maximum_attempts.times { attempt("WrongPassword999!") } |
| 55 | + |
| 56 | + expect(admin.reload).to be_access_locked |
| 57 | + end |
| 58 | + |
| 59 | + it "rejects a request from a locked account" do |
| 60 | + admin.lock_access! |
| 61 | + |
| 62 | + expect { attempt(password) }.not_to change(UserRole, :count) |
| 63 | + |
| 64 | + expect(response).to have_http_status(401) |
| 65 | + end |
| 66 | + |
| 67 | + it "resets failed_attempts on success" do |
| 68 | + admin.update_column(:failed_attempts, 2) |
| 69 | + |
| 70 | + attempt(password) |
| 71 | + |
| 72 | + expect(response).to have_http_status(:created) |
| 73 | + expect(admin.reload.failed_attempts).to eq(0) |
| 74 | + end |
| 75 | + |
| 76 | + it "does not count a missing current_password" do |
| 77 | + post "/user_roles", |
| 78 | + params: {user_role: {user_id: target.id, role_id: role.id}}, |
| 79 | + headers:, as: :json |
| 80 | + |
| 81 | + expect(response).to have_http_status(401) |
| 82 | + expect(admin.reload.failed_attempts).to eq(0) |
| 83 | + end |
| 84 | +end |
0 commit comments