Skip to content

Commit f831c3e

Browse files
authored
Fix account deletion dead ends and misleading login errors (#598)
1 parent 7226637 commit f831c3e

12 files changed

Lines changed: 340 additions & 54 deletions

‎app/controllers/api/v8/users_controller.rb‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ class UsersController < Api::V8::BaseController
110110
end
111111
end
112112

113-
skip_authorization_check only: %i[set_password_managed_by_courses_mooc_fi]
113+
skip_authorization_check only: %i[set_password_managed_by_courses_mooc_fi destroy]
114114

115115
def show
116116
unauthorize_guest! if current_user.guest?
@@ -209,11 +209,14 @@ def update
209209
}, status: :bad_request
210210
end
211211

212+
# courses.mooc.fi retries account deletion and keys off success/already_deleted/code, so a
213+
# user that is already gone must answer 200, never 404.
212214
def destroy
213-
unauthorize_guest! if current_user.guest?
215+
return render_destroy_failure('not_authorized', 'Authentication required', :unauthorized) if current_user.guest?
214216

215-
user = User.find(params[:id])
216-
authorize! :destroy, user
217+
user = User.find_by(id: params[:id])
218+
return render json: { success: true, already_deleted: true } if user.nil?
219+
return render_destroy_failure('not_authorized', 'Forbidden', :forbidden) unless can?(:destroy, user)
217220

218221
User.transaction do
219222
if user.destroy
@@ -226,11 +229,14 @@ def destroy
226229
)
227230
Doorkeeper::AccessToken.where(resource_owner_id: user.id).delete_all
228231

229-
render json: { success: true, message: 'User deleted.' }
232+
render json: { success: true, already_deleted: false, message: 'User deleted.' }
230233
else
231-
render json: { success: false, errors: user.errors }, status: :bad_request
234+
render_destroy_failure('destroy_failed', user.errors.full_messages, :bad_request)
232235
end
233236
end
237+
rescue StandardError => e
238+
Rails.logger.error("Deleting user #{params[:id]} failed: #{e.class}: #{e.message}")
239+
render_destroy_failure('internal_error', 'User deletion failed.', :internal_server_error)
234240
end
235241

236242
def set_password_managed_by_courses_mooc_fi
@@ -277,6 +283,10 @@ def get_user_with_email
277283
end
278284

279285
private
286+
def render_destroy_failure(code, messages, status)
287+
render json: { success: false, code: code, errors: [*messages] }, status: status
288+
end
289+
280290
def set_email
281291
user_params = params[:user]
282292

‎app/controllers/sessions_controller.rb‎

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,19 @@ def create
1818
rescue StandardError
1919
end
2020

21-
user = begin
22-
User.authenticate(params[:session][:login], params[:session][:password])
23-
rescue Faraday::Error => e
24-
# courses.mooc.fi delegated authentication is unreachable; fail gracefully instead of 500.
25-
Rails.logger.error("Login temporarily unavailable due to courses.mooc.fi error: #{e.class}: #{e.message}")
26-
return try_to_redirect_incorrect_login(alert: 'Login is temporarily unavailable. Please try again shortly.')
27-
end
21+
user, status = User.authenticate_with_status(params[:session][:login], params[:session][:password])
2822

29-
redirect_params = {}
30-
if user.nil?
31-
msg = 'Invalid credentials. Try again.'
32-
redirect_params = { alert: msg }
33-
return try_to_redirect_incorrect_login(redirect_params)
34-
else
23+
case status
24+
when :accepted
3525
sign_in user
26+
try_to_redirect_back
27+
when :unavailable
28+
try_to_redirect_incorrect_login(alert: 'Login is temporarily unavailable. Please try again shortly.')
29+
when :misconfigured
30+
try_to_redirect_incorrect_login(alert: 'Your account needs manual attention before you can log in. Please contact support.')
31+
else
32+
try_to_redirect_incorrect_login(alert: 'Invalid credentials. Try again.')
3633
end
37-
38-
try_to_redirect_back(redirect_params)
3934
end
4035

4136
def destroy

‎app/controllers/users_controller.rb‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,9 @@ def destroy_user
129129
return
130130
end
131131
user = authenticate_current_user_destroy
132-
user_authentication = User.authenticate(user.login, params[:user][:password])
133-
if user_authentication.nil?
134-
redirect_to verify_destroying_user_url, alert: 'The password was incorrect.'
132+
_authenticated_user, status = User.authenticate_with_status(user.login, params[:user][:password])
133+
unless status == :accepted
134+
redirect_to verify_destroying_user_url, alert: failed_authentication_alert(status)
135135
return
136136
end
137137
VerificationToken.delete_user.find_by!(user: user, token: params[:id])
@@ -159,6 +159,17 @@ def authenticate_current_user_destroy
159159
user
160160
end
161161

162+
def failed_authentication_alert(status)
163+
case status
164+
when :unavailable
165+
'We could not reach the system that holds your password. Please try again in a few minutes, or contact support if this keeps happening.'
166+
when :misconfigured
167+
'Your account needs manual attention before it can be deleted. Please contact support.'
168+
else
169+
'The password was incorrect.'
170+
end
171+
end
172+
162173
def set_email
163174
user_params = params[:user]
164175

‎app/models/course_template_refresh.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# frozen_string_literal: true
22

33
class CourseTemplateRefresh < ApplicationRecord
4-
belongs_to :user
4+
belongs_to :user, optional: true
55
belongs_to :course_template
66
has_many :course_template_refresh_phases, dependent: :delete_all
77
has_one :course_template_refresh_report, dependent: :destroy

‎app/models/user.rb‎

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class User < ApplicationRecord
2323
has_many :unlocks, dependent: :delete_all
2424
has_many :uncomputed_unlocks, dependent: :delete_all
2525
has_many :reviews, foreign_key: :reviewer_id, inverse_of: :reviewer, dependent: :nullify
26+
has_many :course_template_refreshes, dependent: :nullify
2627
has_many :course_notifications
2728
has_many :comments
2829
has_many :certificates
@@ -159,28 +160,37 @@ def has_password?(submitted_password)
159160
result
160161
end
161162

163+
# The user when the password is accepted, nil otherwise. Use .authenticate_with_status when the
164+
# outcome is shown to the user, so that a delegation failure is not reported as a wrong password.
162165
def self.authenticate(login, submitted_password)
163-
return nil unless login
166+
user, status = authenticate_with_status(login, submitted_password)
167+
status == :accepted ? user : nil
168+
end
169+
170+
# [user, status] where status is :accepted, :rejected when the password was judged wrong,
171+
# :unavailable when courses.mooc.fi could not be reached, or :misconfigured when the account
172+
# cannot be delegated at all and only an admin can repair it. A found user is returned whatever
173+
# the status, so sign in only on :accepted.
174+
def self.authenticate_with_status(login, submitted_password)
175+
return [nil, :rejected] unless login
164176
login = login.strip
165177
user = find_by(login: login)
166178
user ||= find_by('lower(email) = ?', login.downcase)
167-
return nil if user.nil?
179+
return [nil, :rejected] if user.nil?
168180

169-
if user.managed_externally?
170-
return user if user.authenticate_via_courses_mooc_fi(submitted_password)
171-
return nil
172-
elsif user.externally_managed_without_target?
173-
# Half-migrated/misconfigured: flagged as managed by courses.mooc.fi but with no id to
174-
# delegate to. Fail closed instead of silently authenticating against the stale local hash.
181+
return [user, user.courses_mooc_fi_authentication_status(submitted_password)] if user.managed_externally?
182+
183+
if user.externally_managed_without_target?
184+
# Fail closed instead of silently authenticating against the stale local hash.
175185
Rails.logger.error("User #{user.id} is password_managed_by_courses_mooc_fi but has no courses_mooc_fi_user_id; refusing local fallback authentication")
176-
return nil
186+
return [user, :misconfigured]
177187
end
178188

179-
if user.has_password?(submitted_password)
180-
# Locally-managed user logged in: migrate them to courses.mooc.fi
181-
user.post_new_user_to_courses_mooc_fi(submitted_password)
182-
user
183-
end
189+
return [user, :rejected] unless user.has_password?(submitted_password)
190+
191+
# Locally-managed user logged in: migrate them to courses.mooc.fi
192+
user.post_new_user_to_courses_mooc_fi(submitted_password)
193+
[user, :accepted]
184194
end
185195

186196
# The password is stored in courses.mooc.fi and we have the id needed to delegate auth/changes.
@@ -195,7 +205,9 @@ def externally_managed_without_target?
195205
end
196206

197207

198-
def authenticate_via_courses_mooc_fi(submitted_password)
208+
# :accepted, :rejected when courses.mooc.fi judged the password wrong, or :unavailable when it
209+
# could not be consulted. Raises nothing, so that an outage is never mistaken for a rejection.
210+
def courses_mooc_fi_authentication_status(submitted_password)
199211
auth_url = courses_mooc_fi_url('/api/v0/tmc-server/users/authenticate')
200212

201213
conn = courses_mooc_fi_connection
@@ -211,22 +223,29 @@ def authenticate_via_courses_mooc_fi(submitted_password)
211223
}
212224
end
213225

226+
unless response.success?
227+
Rails.logger.error(
228+
"Authentication via courses.mooc.fi failed for user #{self.email}: status=#{response.status}, request-id=#{response.headers['request-id']}, body=#{response.body.inspect}"
229+
)
230+
return :unavailable
231+
end
232+
214233
if response.body == true
215234
clear_stale_local_password
216-
return true
235+
return :accepted
217236
end
218237

219238
Rails.logger.warn(
220239
"Authentication via courses.mooc.fi rejected for user #{self.email}: status=#{response.status}, request-id=#{response.headers['request-id']}, body=#{response.body.inspect}"
221240
)
222-
false
241+
:rejected
223242

224243
rescue Faraday::ClientError => e
225244
status = e.response&.dig(:status)
226245
request_id = e.response&.dig(:headers, 'request-id')
227246
body = e.response&.dig(:body)
228247
Rails.logger.error("Authentication via courses.mooc.fi error for user #{self.email}: status=#{status}, request-id=#{request_id}, body=#{body.inspect}")
229-
raise
248+
:unavailable
230249

231250
rescue => e
232251
if defined?(response) && response
@@ -236,7 +255,7 @@ def authenticate_via_courses_mooc_fi(submitted_password)
236255
else
237256
Rails.logger.error("Unexpected error during authentication via courses.mooc.fi for user #{self.email} (no response): #{e.message}")
238257
end
239-
raise
258+
:unavailable
240259
end
241260

242261

‎app/views/courses/show.html.erb‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,9 +201,10 @@
201201
<% if @course.refreshed_at %>
202202
<li>
203203
Last refreshed at <%= @course.refreshed_at.strftime("%d.%m.%Y %H:%M:%S") %>
204-
<% if @course.course_template.course_template_refreshes.any? %>
205-
by <%= @course.course_template.course_template_refreshes.last.user.login %>
206-
<%= link_to 'Show last report', organization_course_path(@organization, @course, generate_report: @course.course_template.course_template_refreshes.last.id), class: "btn btn-link" %>
204+
<% last_refresh = @course.course_template.course_template_refreshes.last %>
205+
<% if last_refresh %>
206+
<% if last_refresh.user %>by <%= last_refresh.user.login %><% end %>
207+
<%= link_to 'Show last report', organization_course_path(@organization, @course, generate_report: last_refresh.id), class: "btn btn-link" %>
207208
<% end %>
208209
</li>
209210
<% end %>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
class AllowNullUserOnCourseTemplateRefreshes < ActiveRecord::Migration[7.1]
2+
def up
3+
change_column_null :course_template_refreshes, :user_id, true
4+
remove_foreign_key :course_template_refreshes, :users
5+
add_foreign_key :course_template_refreshes, :users, on_delete: :nullify
6+
end
7+
8+
def down
9+
remove_foreign_key :course_template_refreshes, :users
10+
change_column_null :course_template_refreshes, :user_id, false
11+
add_foreign_key :course_template_refreshes, :users
12+
end
13+
end

‎db/schema.rb‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
1212

13-
ActiveRecord::Schema[7.1].define(version: 2025_07_23_122117) do
13+
ActiveRecord::Schema[7.1].define(version: 2026_09_17_120000) do
1414
# These are extensions that must be enabled in order to support this database
1515
enable_extension "plpgsql"
1616

@@ -129,7 +129,7 @@
129129
t.integer "status", default: 0, null: false
130130
t.decimal "percent_done", precision: 10, scale: 4, default: "0.0", null: false
131131
t.jsonb "langs_refresh_output"
132-
t.integer "user_id", null: false
132+
t.integer "user_id"
133133
t.integer "course_template_id", null: false
134134
t.index ["course_template_id"], name: "index_course_template_refreshes_on_course_template_id"
135135
t.index ["user_id"], name: "index_course_template_refreshes_on_user_id"
@@ -564,7 +564,7 @@
564564
add_foreign_key "course_template_refresh_phases", "course_template_refreshes"
565565
add_foreign_key "course_template_refresh_reports", "course_template_refreshes"
566566
add_foreign_key "course_template_refreshes", "course_templates"
567-
add_foreign_key "course_template_refreshes", "users"
567+
add_foreign_key "course_template_refreshes", "users", on_delete: :nullify
568568
add_foreign_key "courses", "organizations"
569569
add_foreign_key "exercises", "courses", on_delete: :cascade
570570
add_foreign_key "feedback_answers", "feedback_questions", on_delete: :cascade

‎spec/controllers/api/v8/users_controller_spec.rb‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,87 @@ def do_update(old_password)
164164
end
165165
end
166166

167+
describe 'DELETE destroy' do
168+
before :each do
169+
controller.current_user = admin
170+
end
171+
172+
it 'deletes the user' do
173+
delete :destroy, params: { id: user.id }
174+
175+
expect(response).to have_http_status(200)
176+
body = JSON.parse(response.body)
177+
expect(body['success']).to eq(true)
178+
expect(body['already_deleted']).to eq(false)
179+
expect(User.find_by(id: user.id)).to be_nil
180+
end
181+
182+
it 'reports an already deleted user as a success instead of a 404' do
183+
deleted_id = user.id
184+
user.destroy!
185+
186+
delete :destroy, params: { id: deleted_id }
187+
188+
expect(response).to have_http_status(200)
189+
body = JSON.parse(response.body)
190+
expect(body['success']).to eq(true)
191+
expect(body['already_deleted']).to eq(true)
192+
end
193+
194+
it 'deletes a user who has refreshed a course template' do
195+
course_template = FactoryBot.create(:course_template)
196+
refresh = CourseTemplateRefresh.create!(user_id: user.id, course_template_id: course_template.id)
197+
198+
delete :destroy, params: { id: user.id }
199+
200+
expect(response).to have_http_status(200)
201+
expect(User.find_by(id: user.id)).to be_nil
202+
expect(refresh.reload.user_id).to be_nil
203+
end
204+
205+
it 'answers a guest with a not_authorized code' do
206+
controller.current_user = Guest.new
207+
208+
delete :destroy, params: { id: user.id }
209+
210+
expect(response).to have_http_status(401)
211+
body = JSON.parse(response.body)
212+
expect(body['success']).to eq(false)
213+
expect(body['code']).to eq('not_authorized')
214+
expect(User.find_by(id: user.id)).not_to be_nil
215+
end
216+
217+
it "answers another user's deletion attempt with a not_authorized code" do
218+
controller.current_user = other_user
219+
220+
delete :destroy, params: { id: user.id }
221+
222+
expect(response).to have_http_status(403)
223+
expect(JSON.parse(response.body)['code']).to eq('not_authorized')
224+
expect(User.find_by(id: user.id)).not_to be_nil
225+
end
226+
227+
it 'answers a refused destroy with a destroy_failed code' do
228+
allow_any_instance_of(User).to receive(:destroy).and_return(false)
229+
230+
delete :destroy, params: { id: user.id }
231+
232+
expect(response).to have_http_status(400)
233+
expect(JSON.parse(response.body)['code']).to eq('destroy_failed')
234+
end
235+
236+
it 'answers an unexpected failure with an internal_error code' do
237+
allow_any_instance_of(User).to receive(:destroy).and_raise(ActiveRecord::InvalidForeignKey.new('boom'))
238+
239+
delete :destroy, params: { id: user.id }
240+
241+
expect(response).to have_http_status(500)
242+
body = JSON.parse(response.body)
243+
expect(body['success']).to eq(false)
244+
expect(body['code']).to eq('internal_error')
245+
end
246+
end
247+
167248
describe 'POST set_password_managed_by_courses_mooc_fi' do
168249
before :each do
169250
controller.current_user = admin

0 commit comments

Comments
 (0)