Skip to content

Commit c5902a4

Browse files
authored
Require current password for sensitive user changes (#512)
Adds re-authentication (current password) to sensitive account actions, so a session cannot make them without the acting user proving their identity. Changes: - Password change: sets config.check_current_password_before_update = :password. Previously current_password was validated only if supplied; omitting it bypassed the check. Now it is required whenever the password changes. The token-based reset flow stays exempt (allow_password_change bypasses the check in DTA). - Role changes: require_current_password! gates all role assignment and removal on UserRolesController (not just promotions, so it stays correct if non-hierarchical roles are added later). Re-authentication runs after authorization, so a user who isn't permitted to change roles gets 403 (forbidden) rather than being prompted for a password. - Email change: same gate on UsersController#update, but only when the email is actually changing. Dormant for SG (update_email is disabled, so email is stripped from permitted attributes); active on installs that enable email editing. The gate (require_current_password! in ApplicationController) is defence-in-depth: the client sends current_password on these requests, so it primarily blocks direct API calls (e.g. a hijacked session). Existing UserRoles controller and API specs updated to send current_password on the success cases and to reflect the authorization-before-re-authentication ordering. Verified on UAT: password change and role assign/remove reject without current_password (401/422) and succeed with it. Email gate covered by spec (stubbed on), since it is disabled on this build. Depends on #508. Based on that branch; the email gate builds on its permitted-attributes change. Client side implementation still TODO
1 parent ca2fa06 commit c5902a4

10 files changed

Lines changed: 342 additions & 13 deletions

app/controllers/application_controller.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,30 @@ def user_not_authorized
8585
def skip_authentication?
8686
devise_or_devise_token_auth_controller? || action_name == "index"
8787
end
88+
89+
# Re-authentication gate for sensitive actions (role and email changes).
90+
# Routed through Devise's valid_for_authentication? so failures count toward
91+
# :lockable - a bare valid_password? here would be an unthrottled password
92+
# oracle, usable even while the account is locked out of sign-in.
93+
#
94+
# Success resets failed_attempts explicitly: Devise normally does that in a
95+
# Warden after_set_user hook, which does not fire on this path.
96+
#
97+
def require_current_password!
98+
password = request.request_parameters[:current_password]
99+
100+
if password.present? &&
101+
current_user&.valid_for_authentication? { current_user.valid_password?(password) }
102+
if current_user.failed_attempts.to_i.positive?
103+
current_user.update_column(:failed_attempts, 0)
104+
end
105+
return true
106+
end
107+
108+
render json: {
109+
status: "error",
110+
errors: {current_password: ["is incorrect or missing"]}
111+
}, status: :unauthorized
112+
false
113+
end
88114
end

app/controllers/user_roles_controller.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
class UserRolesController < ApplicationController
22
before_action :set_and_authorize_user_role, only: [:destroy]
3+
before_action :require_current_password!, only: [:destroy]
34

45
# GET /user_roles
56
def index
@@ -14,6 +15,7 @@ def create
1415
@user_role = UserRole.new
1516
@user_role.assign_attributes(permitted_attributes(@user_role))
1617
authorize @user_role
18+
return unless require_current_password!
1719

1820
if @user_role.save
1921
render json: serialize(@user_role), status: :created, location: @user_role

app/controllers/users_controller.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
class UsersController < ApplicationController
22
before_action :authenticate_user!
3-
43
before_action :set_and_authorize_user, only: [:update]
4+
before_action :require_current_password!, only: [:update], if: :email_change_requested?
55

66
# GET /users
77
def index
@@ -41,4 +41,9 @@ def set_and_authorize_user
4141
@user = policy_scope(base_object).find(params[:id])
4242
authorize @user
4343
end
44+
45+
def email_change_requested?
46+
new_email = permitted_attributes(@user)[:email]
47+
new_email.present? && new_email != @user.email
48+
end
4449
end

config/initializers/devise_token_auth.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
# Uncomment to enforce current_password param to be checked before all
3030
# attribute updates. Set it to :password if you want it to be checked only if
3131
# password is updated.
32-
# config.check_current_password_before_update = :attributes
32+
config.check_current_password_before_update = :password
3333

3434
# By default we will use callbacks for single omniauth.
3535
# It depends on fields like email, provider and uid.

spec/controllers/user_roles_controller_spec.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
require "json"
33

44
RSpec.describe UserRolesController, type: :controller do
5+
let(:current_password) { "SecurePassword123!" }
6+
57
describe "Get index" do
68
subject { get :index, format: :json }
79

@@ -127,6 +129,7 @@ def self.all_roles
127129
sign_in user
128130

129131
response = post :create, format: :json, params: {
132+
current_password: current_password,
130133
user_role: {
131134
user_id: target_user.id,
132135
role_id: target_role_record.id
@@ -156,6 +159,7 @@ def self.all_roles
156159
sign_in user
157160

158161
response = post :create, format: :json, params: {
162+
current_password: current_password,
159163
user_role: {
160164
user_id: target_user.id,
161165
role_id: target_role_record.id
@@ -196,6 +200,7 @@ def self.all_roles
196200
admin = FactoryBot.create(:user, :admin)
197201
sign_in admin
198202
post :create, format: :json, params: {
203+
current_password: current_password,
199204
user_role: {description: "desc only", taxonomy_id: 999}
200205
}
201206
expect(response).to have_http_status(422)
@@ -207,6 +212,7 @@ def self.all_roles
207212
sign_in admin
208213

209214
response = post :create, format: :json, params: {
215+
current_password: current_password,
210216
user_role: {
211217
user_id: guest.id,
212218
role_id: admin_role.id
@@ -262,6 +268,7 @@ def self.all_roles
262268
sign_in user
263269

264270
response = delete :destroy, format: :json, params: {
271+
current_password: current_password,
265272
id: target_user.user_roles.first.id
266273
}
267274
expect(response).to be_no_content
@@ -288,6 +295,7 @@ def self.all_roles
288295
sign_in user
289296

290297
response = delete :destroy, format: :json, params: {
298+
current_password: current_password,
291299
id: target_user.user_roles.first.id
292300
}
293301
expect(response).to be_no_content
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe "Email change requires current password", type: :request do
6+
let(:password) { "SecurePassword123!" }
7+
let(:admin) { FactoryBot.create(:user, :admin, password:, password_confirmation: password) }
8+
9+
before do
10+
allow_any_instance_of(UserPolicy).to receive(:permitted_attributes)
11+
.and_return([:name, :email])
12+
end
13+
14+
def sign_in_headers
15+
allow(Rails.application.config).to receive(:enable_mfa).and_return(false)
16+
post "/auth/sign_in", params: {email: admin.email, password:}, as: :json
17+
expect(response).to have_http_status(:success)
18+
{
19+
"access-token" => response.headers["access-token"],
20+
"client" => response.headers["client"],
21+
"uid" => response.headers["uid"]
22+
}
23+
end
24+
25+
it "rejects an email change without current_password" do
26+
headers = sign_in_headers
27+
28+
put "/users/#{admin.id}",
29+
params: {user: {email: "new@example.com"}}, headers:, as: :json
30+
31+
expect(response).to have_http_status(401)
32+
expect(admin.reload.email).not_to eq("new@example.com")
33+
end
34+
35+
it "allows an email change with correct current_password" do
36+
headers = sign_in_headers
37+
38+
put "/users/#{admin.id}",
39+
params: {current_password: password, user: {email: "new@example.com"}},
40+
headers:, as: :json
41+
42+
expect(response).to have_http_status(:success)
43+
expect(admin.reload.email).to eq("new@example.com")
44+
end
45+
46+
it "does not require current_password for a name-only update" do
47+
headers = sign_in_headers
48+
49+
put "/users/#{admin.id}",
50+
params: {user: {name: "New Name"}}, headers:, as: :json
51+
52+
expect(response).to have_http_status(:success)
53+
expect(admin.reload.name).to eq("New Name")
54+
end
55+
end
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe "In-app password change requires current password", type: :request do
6+
let(:current_password) { "SecurePassword123!" }
7+
let(:new_password) { "SecurePassword456!" }
8+
9+
let(:user) do
10+
FactoryBot.create(:user, password: current_password, password_confirmation: current_password)
11+
end
12+
13+
def sign_in_headers
14+
allow(Rails.application.config).to receive(:enable_mfa).and_return(false)
15+
post "/auth/sign_in", params: {email: user.email, password: current_password}, as: :json
16+
expect(response).to have_http_status(:success)
17+
{
18+
"access-token" => response.headers["access-token"],
19+
"client" => response.headers["client"],
20+
"uid" => response.headers["uid"]
21+
}
22+
end
23+
24+
it "rejects a password change without current_password" do
25+
headers = sign_in_headers
26+
27+
put "/auth",
28+
params: {password: new_password, password_confirmation: new_password},
29+
headers: headers, as: :json
30+
31+
expect(response).to have_http_status(422)
32+
expect(user.reload.valid_password?(current_password)).to be(true)
33+
expect(user.reload.valid_password?(new_password)).to be(false)
34+
end
35+
36+
it "rejects a password change with an incorrect current_password" do
37+
headers = sign_in_headers
38+
39+
put "/auth",
40+
params: {
41+
current_password: "WrongPassword999!",
42+
password: new_password,
43+
password_confirmation: new_password
44+
},
45+
headers: headers, as: :json
46+
47+
expect(response).to have_http_status(422)
48+
expect(user.reload.valid_password?(current_password)).to be(true)
49+
expect(user.reload.valid_password?(new_password)).to be(false)
50+
end
51+
52+
it "allows a password change with the correct current_password" do
53+
headers = sign_in_headers
54+
55+
put "/auth",
56+
params: {
57+
current_password: current_password,
58+
password: new_password,
59+
password_confirmation: new_password
60+
},
61+
headers: headers, as: :json
62+
63+
expect(response).to have_http_status(:success)
64+
expect(user.reload.valid_password?(new_password)).to be(true)
65+
end
66+
end

0 commit comments

Comments
 (0)