Module: Controllers::Authenticable

Extended by:
ActiveSupport::Concern
Includes:
Devise::Controllers::Helpers, Memery
Included in:
ApplicationController
Defined in:
app/concerns/controllers/authenticable.rb

Overview

Provides authentication and user session management for controllers.
Handles guest user creation, account authentication, and session transfers.

Instance Method Summary collapse

Instance Method Details

#access_deniedObject

Redirect as appropriate when an access request fails.

The default action is to redirect to the login screen.

Override this method in your controllers if you want to have special
behavior in case the account is not authorized
to access the requested action. For example, a popup window might
simply close itself.



235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'app/concerns/controllers/authenticable.rb', line 235

def access_denied
  respond_to do |format|
    format.html do
      if 
        flash[:error] = t('controllers.authenticable.access_denied', path: request.fullpath, method: request.method)
        redirect_to(request.referer || cms_link('/my_account'))
      else
        flash[:info] = t('controllers.authenticable.sign_in_to_proceed')
        redirect_to (devise_return_path: request.fullpath)
      end
    end
    format.any(:js, :xml) do
      request_http_basic_authentication 'Web Password'
    end
  end
end

#authenticate_account(options = {}) ⇒ Object



268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'app/concerns/controllers/authenticable.rb', line 268

def (options = {})
  Rails.logger.debug { "authenticate_account: options: #{options.inspect}" }
   = CGI.unescape(params[:account_email] || options[:account_email] || '')
  Rails.logger.debug { "authenticate_account: account_email: #{}" }
  fall_back_path = request.get? ? request.fullpath : request.referer
  devise_return_path = options[:after_authenticate_path] || cms_link(fall_back_path)
  logger.debug "Authenticable#authenticate_account: devise_return_path: #{devise_return_path}"
  flash[:info] = t('controllers.authenticable.sign_in_to_proceed')
  respond_to do |format|
    format.html { redirect_to (devise_return_path: devise_return_path, login: ) }
    format.pdf { redirect_to (devise_return_path: devise_return_path, login: ) }
    format.any { head :not_found }
  end
end

#authenticate_account!(options = {}) ⇒ Object



252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'app/concerns/controllers/authenticable.rb', line 252

def authenticate_account!(options = {})
  # Use scrubbed_request_path (not request.fullpath) so this debug line
  # never leaks `?login_token=` or the legacy `?auth_token=` bearer into
  # local or AppSignal logs. Rails' filter_parameters protects structured
  # params logging but the raw fullpath string bypasses that filter.
  logger.debug "Authenticable#authenticate_account!: cms_link(request.fullpath): #{cms_link(scrubbed_request_path)}"
  unless  || 
    if 
      super
    else
      
    end
  end
  set_paper_trail_whodunnit
end

#authenticate_account_from_login_token!Object

Sign the account in from a Rails-signed ?login_token= param. This is
the new magic-link path — purpose-tagged via signed_id so a token
minted for one flow (e.g. cart recovery) cannot be replayed against
another. Mounted before the legacy authenticate_account_from_token!
in authenticate_account! so new URLs always use this path; only
in-flight ?auth_token= emails still fall through to the legacy decoder.

Account.find_signed (no bang) returns nil on bad/expired/wrong-purpose
tokens — quiet failure that falls through to other auth strategies.



315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
# File 'app/concerns/controllers/authenticable.rb', line 315

def 
  return false unless params[:login_token].present?

   = Account.find_signed(params[:login_token], purpose: :magic_login)
  return false unless 
  return () || true if .disabled?

  existing_session_conflict?() && (return true)

  logger.debug "authenticate_account_from_login_token! signing in account #{.id}"
  # Tag the LoginActivity row written by AuthTrail's after_set_user hook with
  # `strategy='magic_link'` BEFORE sign_in fires, since transform_method runs
  # synchronously inside the sign_in call (see config/initializers/authtrail.rb).
  request.env[AUTHTRAIL_TRACKED_STRATEGY_ENV_KEY] = 'magic_link'
  res = (:account, , store: true)
  init_current_user if res

  # Strip the bearer token from the URL after a successful sign-in so it
  # doesn't linger in the address bar, browser history, or downstream
  # `Referer` headers (which leak to third-party assets and analytics).
  # Only meaningful for GET — POST bodies don't end up in URL surface area.
  # The redirect re-enters the controller authenticated via session so the
  # original action runs cleanly without the token in the URL.
  if res && request.get?
    redirect_to scrubbed_request_path
    return true
  end

  res
end

#authenticate_account_from_token!Object



357
358
359
360
361
362
363
364
365
# File 'app/concerns/controllers/authenticable.rb', line 357

def 
  return false unless params[:auth_token].present? && (params[:account_email].present? || params[:account_login].present?)

   = 
  return false unless 

  existing_session_conflict?() && (return true)
  ()
end

#check_is_a_managerObject



194
195
196
197
198
# File 'app/concerns/controllers/authenticable.rb', line 194

def check_is_a_manager
  return if &.is_manager?

  access_denied && return
end

#check_is_a_sales_managerObject



200
201
202
203
204
# File 'app/concerns/controllers/authenticable.rb', line 200

def check_is_a_sales_manager
  return if &.is_sales_manager?

  access_denied && return
end

#check_is_an_adminObject



206
207
208
# File 'app/concerns/controllers/authenticable.rb', line 206

def check_is_an_admin
  access_denied && return unless &.is_admin?
end

#check_is_an_employeeObject



188
189
190
191
192
# File 'app/concerns/controllers/authenticable.rb', line 188

def check_is_an_employee
  return if &.is_employee?

  access_denied && return
end

#check_partyObject



210
211
212
213
214
215
216
217
218
# File 'app/concerns/controllers/authenticable.rb', line 210

def check_party
  if session['devise.online_customer_party_id'].present? &&
     @context_user.customer.self_and_contacts_party_ids_arr.include?(session['devise.online_customer_party_id'].to_i) &&
     @context_user.can_list_all_contact_resources?
    @party = Party.find(session['devise.online_customer_party_id'])
  else
    session['devise.online_customer_party_id'] = nil
  end
end

#clear_mismatched_guest_userObject

If there is a current user and a guest user id which doesn't match we clear things up



35
36
37
38
39
40
41
42
43
44
45
# File 'app/concerns/controllers/authenticable.rb', line 35

def clear_mismatched_guest_user
  return unless session[:guest_user_id]

  logger.debug 'Guest user detected'
  if session[:guest_user_id] != current_user.id
    logger.debug 'Guest user id different than logged in user, transferring and cleaning up'
    tmp_guest_user = Customer.where(id: session[:guest_user_id]).first
    logging_in(, tmp_guest_user) if tmp_guest_user
  end
  session[:guest_user_id] = nil
end

#create_guest_userObject



295
296
297
298
299
300
301
302
303
304
# File 'app/concerns/controllers/authenticable.rb', line 295

def create_guest_user
  u = initialize_guest
  u.save!
  # rubocop:disable Rails/SkipsModelValidations -- Performance: skip validations for self-referential creator
  u.update_column(:creator_id, u.id)
  # rubocop:enable Rails/SkipsModelValidations
  logger.debug "Creating a new guest user, customer id #{u.id}"
  CurrentScope.user = u
  u
end

#credentials?Boolean

Returns:

  • (Boolean)


367
368
369
# File 'app/concerns/controllers/authenticable.rb', line 367

def credentials?
  @context_user.try(:account).present?
end

#current_or_guest_userObject

if user is logged in, return current_user, else return guest_user



21
22
23
24
25
26
27
28
# File 'app/concerns/controllers/authenticable.rb', line 21

def current_or_guest_user
  if 
    clear_mismatched_guest_user
    current_user
  else
    guest_user
  end
end

#current_or_guest_user_id_read_onlyObject



30
31
32
# File 'app/concerns/controllers/authenticable.rb', line 30

def current_or_guest_user_id_read_only
  current_user&.id || session[:guest_user_id].presence
end

#current_userObject

Accesses the current LOGGED IN user from the session.



173
174
175
# File 'app/concerns/controllers/authenticable.rb', line 173

def current_user
  .try(:party)
end

#devise_mappingObject



63
64
65
# File 'app/concerns/controllers/authenticable.rb', line 63

def devise_mapping
  @devise_mapping ||= Devise.mappings[:account]
end

#fully_logged_in?Boolean

Returns:

  • (Boolean)


51
52
53
# File 'app/concerns/controllers/authenticable.rb', line 51

def fully_logged_in?
  .present?
end

#generate_bot_idObject

Generate a bot id comprising the agent and locale of the request params



115
116
117
# File 'app/concerns/controllers/authenticable.rb', line 115

def generate_bot_id
  "#{(request.user_agent || 'unknown')[0..240]} (#{params[:locale] || '-'})" # limit to less than 255 when we get garbage
end

#guest_user(skip_creation: false) ⇒ Object

find guest_user object associated with the current session,
creating one as needed, destroying the guest is it is associated with an account



121
122
123
124
125
126
127
128
129
130
# File 'app/concerns/controllers/authenticable.rb', line 121

def guest_user(skip_creation: false)
  c = find_existing_guest_user

  # At this point we need to create one
  c ||= create_guest_user unless skip_creation
  # This ensures we record the id of our created guest or of the pulled guest (merged could have occurred)
  session[:guest_user_id] = c&.id
  # Don't bother tracking versions records for this request
  c
end

#identifiable?Boolean

Returns:

  • (Boolean)


47
48
49
# File 'app/concerns/controllers/authenticable.rb', line 47

def identifiable?
   || (session[:guest_user_id].present? && Customer.where(id: session[:guest_user_id]).present?)
end

#init_current_userObject



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'app/concerns/controllers/authenticable.rb', line 147

def init_current_user
  logger.tagged 'init_current_user' do
    # set omniauth session if present
    session['devise.omniauth_data'] = request.env['omniauth.auth'].except('extra') if request.env['omniauth.auth']
    u = nil
    if /^crm/.match?(request.subdomain)
      u = current_user
    else # A plain web user
      u = current_or_guest_user
      session['devise.online_customer_party_id'] = u.customer_id
    end
    if u
      logger.debug "Current user set to #{u.id} #{u.full_name}"
      CurrentScope.user = @context_user = u
      # The visit id will be stored in the session from the track action
      CurrentScope.visit_id ||= session[:visit_id]
      set_paper_trail_whodunnit if respond_to? :set_paper_trail_whodunnit
      # Tag AppSignal with user context for error tracking
      tag_appsignal_user(u)
    else
      logger.error 'Current user is empty'
    end
  end
end

#initialize_guestObject



283
284
285
286
287
288
289
290
291
292
293
# File 'app/concerns/controllers/authenticable.rb', line 283

def initialize_guest
  if bot_request?
    name = bot_id = generate_bot_id
    logger.debug "Bot detected #{name}"
  else
    name = Haikunator.haikunate(9999, ' ').titleize
    source_id = Tracking::Tracker.find_source_from_request(params: params, request: request)&.id
  end

  build_guest_customer(name: name, source_id: source_id, bot_id: bot_id)
end

#load_context_user(customer_id) ⇒ Object

Resolve a stored session[:guest_user_id] to the Customer record we should
treat as @context_user for an anonymous visitor.

IMPORTANT: this MUST stay strict. Two foot-guns we explicitly defend
against, both of which caused real customer cross-contamination:

  1. If a guest party gets promoted to lead_qualify / lead / customer
    (e.g. a CRM rep takes a quote, a "Get a quote" form is submitted),
    a stale session[:guest_user_id] in some OTHER browser must NOT
    silently re-bind that browser to the now-real customer. Otherwise
    a later POST /register on that other browser would overwrite the
    real customer's account & identity.

  2. If a guest party is merged into a real customer, the previous
    merged_from_ids.contains([id]) fallback re-bound the old guest
    cookie to the merged-into customer. That made every subsequent
    anonymous request on that browser silently impersonate the real
    customer. We drop that fallback entirely - if the guest party is
    gone, treat the visitor as a fresh guest.



86
87
88
89
90
91
92
93
# File 'app/concerns/controllers/authenticable.rb', line 86

def load_context_user(customer_id)
  email_sub_query = "(select detail from contact_points cp where cp.party_id = parties.id and cp.category = 'email' order by position limit 1) as email".sql_safe

  Customer.where(id: customer_id, state: 'guest')
          .includes(account: :roles)
          .select_append(email_sub_query)
          .first || warn_on_session_guest_id_leak(customer_id)
end

#logging_in(cur_account, guest_user) ⇒ Object

called (once) when the user logs in, insert any code your application needs
to hand off from guest_user to current_user.



134
135
136
137
138
139
140
141
142
143
144
145
# File 'app/concerns/controllers/authenticable.rb', line 134

def logging_in(, guest_user)
  return if .nil? || guest_user.nil? || .party_id == guest_user.id

  if .party
    logger.debug "logging_in called, current_account.party.id: #{.party.id}"
    transfer_cart_from_guest(guest_user)
    transfer_opportunities_from_guest(guest_user)
    transfer_room_plans_from_guest(guest_user)
  else
    (guest_user)
  end
end

#resourceObject



59
60
61
# File 'app/concerns/controllers/authenticable.rb', line 59

def resource
  @resource ||= Account.new(email: current_or_guest_user.email)
end

#resource_nameObject



55
56
57
# File 'app/concerns/controllers/authenticable.rb', line 55

def resource_name
  :account
end

#restrict_access_for_non_employeesObject



220
221
222
223
224
225
# File 'app/concerns/controllers/authenticable.rb', line 220

def restrict_access_for_non_employees
  return unless params[:employee_id] && !.has_role?('employee') && params[:employee_id] != @context_user.id

  # don't want non-employees to see opportunities/activities/orders other than their own
  access_denied
end

#scrubbed_request_pathObject

Return the request path with bearer-token query params removed. Used
both for safe debug logging (so tokens never reach log files) and for
the post-sign-in redirect (so tokens never reach browser history /
Referer headers). Mirrors Rails' filter_parameters intent for the URL
surface, which request.fullpath and redirect_to request.fullpath
would otherwise bypass.



352
353
354
355
# File 'app/concerns/controllers/authenticable.rb', line 352

def scrubbed_request_path
  cleaned = request.query_parameters.except('login_token', 'auth_token')
  cleaned.empty? ? request.path : "#{request.path}?#{cleaned.to_query}"
end

#user_objectObject



177
178
179
180
181
182
183
184
# File 'app/concerns/controllers/authenticable.rb', line 177

def user_object
  return empty_user_object unless @context_user

  build_user_object_for(@context_user)
rescue StandardError => e
  ErrorReporting.critical(e)
  empty_user_object
end

#warn_on_session_guest_id_leak(customer_id) ⇒ Object

Telemetry for the case where session[:guest_user_id] points to a row that
exists but is no longer a guest (promoted to lead_qualify/lead/customer
or merged away). Returning nil forces find_session_guest_user to mint a
fresh guest, which is the safe behavior. We warn so we can spot upstream
bugs that left a non-guest id in a visitor's cookie.



100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'app/concerns/controllers/authenticable.rb', line 100

def warn_on_session_guest_id_leak(customer_id)
  leaked = Customer.where(id: customer_id).pick(:id, :state)
  return nil unless leaked

  id, state = leaked
  # Intentionally NO full_name / email / login here: id + state are enough
  # to correlate the incident in AppSignal without leaking PII into logs
  # or the error-reporting payload.
  msg = "[session-guest-leak] session[:guest_user_id]=#{id} resolves to non-guest party (state=#{state.inspect}); minting fresh guest"
  logger.warn msg
  ErrorReporting.warning(msg, leaked_party_id: id, leaked_state: state)
  nil
end