Files
once-campfire/app/models/user.rb
T
Jeremy Daer 417f087964 Ask for the current password to change it
Changing the password now ends every other session, so whoever holds
one could shut the owner out of their own account with a new password.
The profile asks for the current password whenever the password
changes; without it, or with a wrong one, nothing is saved and the page
says why. Other profile edits don't need it, and the admin
reset-password script still works without it.

Attempts are rate limited like signing in, 10 in 3 minutes per address,
so the current password can't be guessed here instead.

From #371. The cached-response test it touches isn't in 1.5.2.

(cherry picked from commit 44a92ff6c6)
2026-10-19 12:17:27 -07:00

99 lines
3.6 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class User < ApplicationRecord
include Avatar, Bannable, Bot, Mentionable, Role, Transferable
has_many :memberships, dependent: :delete_all
has_many :rooms, through: :memberships
has_many :reachable_messages, through: :rooms, source: :messages
has_many :messages, dependent: :destroy, foreign_key: :creator_id
has_many :push_subscriptions, class_name: "Push::Subscription", dependent: :delete_all
has_many :boosts, dependent: :destroy, foreign_key: :booster_id
has_many :searches, dependent: :delete_all
has_many :sessions, dependent: :destroy
has_many :bans, dependent: :destroy
enum :status, %i[ active deactivated banned ], default: :active
has_secure_password validations: false
validate :password_challenge_matches, unless: -> { password_challenge.nil? }
after_create_commit :grant_membership_to_open_rooms
after_update :sign_out_all_sessions, if: :saved_change_to_password_digest?
scope :ordered, -> { order("LOWER(name)") }
scope :filtered_by, ->(query) { where("name like ?", "%#{query}%") }
def initials
name.scan(/\b\w/).join
end
def title
[ name, bio ].compact_blank.join(" – ")
end
def deactivate
transaction do
close_remote_connections
memberships.without_direct_rooms.delete_all
push_subscriptions.delete_all
searches.delete_all
sessions.delete_all
update! status: :deactivated, email_address: deactived_email_address
end
end
def reset_remote_connections
close_remote_connections reconnect: true
end
private
# The profile sets password_challenge whenever it changes the password; scripts that reset one don't.
def password_challenge_matches
unless password_digest_was.present? && BCrypt::Password.new(password_digest_was).is_password?(password_challenge)
errors.add :password_challenge, :invalid
end
end
# A new password is how someone shuts out whoever else got into their account, so it ends every session, copies
# of the changing browser's cookie included. Connections close once that commits, registered on the transaction
# because a later save in it would replace the saved change an after_commit condition reads.
def sign_out_all_sessions
sessions.delete_all
self.class.current_transaction.after_commit { disconnect_signed_out_sessions }
end
# The password is saved by now, so a realtime service that can't be reached mustn't fail the change.
def disconnect_signed_out_sessions
reset_remote_connections
rescue => error
Rails.logger.warn "Could not disconnect remote connections after a password change: #{error.class}"
end
# One statement, which SQLite runs under the write lock, so no room is closed between reading the open ones and
# inserting. A room that Room#destroy_later closes is either closed before, and skipped, or after, and takes this
# membership away with the others. A room that granted itself to every user when it was created is skipped too,
# as insert_all did.
def grant_membership_to_open_rooms
now = Time.current
Membership.connection.execute Membership.sanitize_sql([ <<~SQL, id, now, now ])
insert into memberships (room_id, user_id, created_at, updated_at)
select id, ?, ?, ? from rooms where type = 'Rooms::Open'
on conflict do nothing
SQL
end
def deactived_email_address
email_address&.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@")
end
def close_remote_connections(reconnect: false)
ActionCable.server.remote_connections.where(current_user: self).disconnect reconnect: reconnect
end
end