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_deniedvoid

This method returns an undefined value.

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.



304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
# File 'app/concerns/controllers/authenticable.rb', line 304

def access_denied
  respond_to do |format|
    format.html do
      if 
        flash[:error] = t('controllers.authenticable.access_denied', path: scrubbed_request_path, 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: scrubbed_request_path)
      end
    end
    format.any(:js, :xml) do
      request_http_basic_authentication 'Web Password'
    end
  end
end

#authenticate_account(options = {}) ⇒ void

This method returns an undefined value.

Render the sign-in prompt flow for a guest visitor.

Parameters:

  • options (Hash) (defaults to: {})

    sign-in flow options

Options Hash (options):

  • account_email (String)

    pre-fill the sign-in form with
    this email address

  • after_authenticate_path (String)

    path the user is sent
    to after signing in (defaults to the current request path or referer)



355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
# File 'app/concerns/controllers/authenticable.rb', line 355

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: #{}" }
  # `scrubbed_request_path` strips `?login_token=` (and the legacy
  # `?auth_token=`) so the bearer never lands in `devise_return_path`,
  # which Devise echoes into log lines, the session, and the URL the
  # user is bounced to after sign-in.
  fall_back_path = request.get? ? scrubbed_request_path : 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 = {}) ⇒ void

This method returns an undefined value.

Authenticate the current account, bouncing guests to the sign-in page.

Parameters:

  • options (Hash) (defaults to: {})

    passed through to Devise's authenticate_account!

Options Hash (options):

  • force (Boolean)

    force re-authentication even when a
    session is already active (forwarded to Devise/warden)



327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
# File 'app/concerns/controllers/authenticable.rb', line 327

def authenticate_account!(options = {})
  # Use scrubbed_request_path (not request.fullpath) so this debug line
  # never leaks `?login_token=` into local or AppSignal logs. Rails'
  # filter_parameters protects structured params logging but the raw
  # fullpath string bypasses that filter. cms_link is intentionally NOT
  # called here: it's a storefront helper that prefixes the current
  # I18n.locale (e.g. `:en` → `/en/…`), which is wrong on CRM where URLs
  # are locale-free — produced misleading lines like
  # `cms_link(request.fullpath): /en/crm/navbar_presence` in dev.log.
  logger.debug "Authenticable#authenticate_account!: path=#{scrubbed_request_path}"
  unless 
    if 
      super
    else
      
    end
  end
  set_paper_trail_whodunnit
end

#authenticate_account_from_login_token!Boolean

Sign the account in from a Rails-signed ?login_token= param. This is
the magic-link path — single-use via generates_token_for(:magic_login),
whose signature includes current_sign_in_at so the link dies on the
next sign-in. The token is also purpose-tagged, so a token minted for one
flow (e.g. cart recovery) cannot be replayed against another.

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

The find_signed fallback is transitional: signed_id (PR1's verifier)
and generates_token_for produce different token formats that don't
cross-validate, so without it every cart-recovery link minted before this
deploy would 404 the moment it ships. Remove the fallback in a follow-up
PR ≥14 days after deploy — by then every pre-cutover signed_id token
(7-day TTL) has expired on its own.

Returns:

  • (Boolean)

    true when a token sign-in was handled, false when no
    token was present or it failed validation



421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
# File 'app/concerns/controllers/authenticable.rb', line 421

def 
  return false if params[:login_token].blank?

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

  resolve_existing_session_conflict_for()

  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

#check_is_a_managervoid

This method returns an undefined value.

Restricts the action to manager accounts.



252
253
254
255
256
# File 'app/concerns/controllers/authenticable.rb', line 252

def check_is_a_manager
  return if &.is_manager?

  access_denied && return
end

#check_is_a_sales_managervoid

This method returns an undefined value.

Restricts the action to sales manager accounts.



260
261
262
263
264
# File 'app/concerns/controllers/authenticable.rb', line 260

def check_is_a_sales_manager
  return if &.is_sales_manager?

  access_denied && return
end

#check_is_an_adminvoid

This method returns an undefined value.

Restricts the action to admin accounts.



268
269
270
# File 'app/concerns/controllers/authenticable.rb', line 268

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

#check_is_an_employeevoid

This method returns an undefined value.

Restricts the action to employee accounts.



244
245
246
247
248
# File 'app/concerns/controllers/authenticable.rb', line 244

def check_is_an_employee
  return if &.is_employee?

  access_denied && return
end

#check_partyvoid

This method returns an undefined value.

Sets @party from the session-selected online customer party when the
context user is allowed to act for it; clears the session key otherwise.



275
276
277
278
279
280
281
282
283
# File 'app/concerns/controllers/authenticable.rb', line 275

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_uservoid

This method returns an undefined value.

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



41
42
43
44
45
46
47
48
49
50
51
# File 'app/concerns/controllers/authenticable.rb', line 41

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_userCustomer

Persists a new guest customer for the session and makes it the current
scope user.

Returns:

  • (Customer)

    the newly created guest customer



392
393
394
395
396
397
398
399
400
# File 'app/concerns/controllers/authenticable.rb', line 392

def create_guest_user
  u = initialize_guest
  u.save! # -- 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

Whether the context user has credentials, i.e. an attached account.

Returns:

  • (Boolean)

    true when the context user has an account



468
469
470
# File 'app/concerns/controllers/authenticable.rb', line 468

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

#current_or_guest_userCustomer

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

Returns:

  • (Customer)

    the logged-in or guest customer



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

def current_or_guest_user
  if 
    clear_mismatched_guest_user
    current_user
  else
    guest_user
  end
end

#current_or_guest_user_id_read_onlyInteger?

Returns the id of the current user or the session's guest user without
creating a guest or otherwise mutating state.

Returns:

  • (Integer, nil)

    the customer id, or nil when neither exists



35
36
37
# File 'app/concerns/controllers/authenticable.rb', line 35

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

#current_userParty?

Accesses the current LOGGED IN user from the session.

Returns:

  • (Party, nil)

    the party of the signed-in account, or nil



224
225
226
# File 'app/concerns/controllers/authenticable.rb', line 224

def current_user
  .try(:party)
end

#devise_mappingDevise::Mapping

Devise mapping for the account scope.

Returns:

  • (Devise::Mapping)

    the account Devise mapping



81
82
83
# File 'app/concerns/controllers/authenticable.rb', line 81

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

#fully_logged_in?Boolean

Whether the visitor is signed in with a real account (not just a guest).

Returns:

  • (Boolean)

    true when an account session is active



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

def fully_logged_in?
  .present?
end

#generate_bot_idString

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

Returns:

  • (String)

    the bot id (user agent + locale, capped under 255 chars)



155
156
157
# File 'app/concerns/controllers/authenticable.rb', line 155

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) ⇒ Customer?

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

Parameters:

  • skip_creation (Boolean) (defaults to: false)

    when true, do not create a guest if none
    exists for the session

Returns:

  • (Customer, nil)

    the guest customer, or nil when none exists and
    skip_creation is set



165
166
167
168
169
170
171
172
173
174
# File 'app/concerns/controllers/authenticable.rb', line 165

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

Whether the request can be tied to a known identity: a logged-in account
or a guest session pointing at an existing customer.

Returns:

  • (Boolean)

    true when the visitor is identifiable



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

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

#init_current_uservoid

This method returns an undefined value.

Resolves the current or guest user for the request and wires request-wide
state (CurrentScope, PaperTrail whodunnit, AppSignal tagging).



197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'app/concerns/controllers/authenticable.rb', line 197

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_guestCustomer

Builds (but does not persist) a new guest customer for the request,
using a bot identity for crawlers or a generated name for humans.

Returns:

  • (Customer)

    the unsaved guest customer



377
378
379
380
381
382
383
384
385
386
387
# File 'app/concerns/controllers/authenticable.rb', line 377

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) ⇒ Customer?

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

The defense is "no attached account", not "still state=guest". A
visitor's own party legitimately gets promoted out of guest mid-session
in normal flows: Checkout::CheckoutForm#save (POST /my_cart/checkout_update),
Lead#save_to_user (POST /leads — every contact / "Get info" / trade form
site-wide), and QuoteBuilderController#qualify_lead (quote-builder email
capture and finish_request_plan) all set cust.state = 'lead_qualify' on
the same party in place. Filtering on state caused those visitors to land
on a fresh empty cart on the very next request because @context_user.cart
hung off the now-promoted party.

This MUST refuse customers with a persisted account, or we
re-introduce the cross-contamination foot-gun from BC-498301955: a stale
session pointer to a real account-bound customer would let a later POST
/register overwrite that customer's account and identity. Defense in
depth lives at three layers and all three must hold:

  1. Here, load_context_user filters out anything with an account.
  2. CustomerSessionsController#registration_would_clobber_existing_identity?
    refuses register POSTs when the existing party has any persisted
    account (pinned by IdentityBindingSafetyTest).
  3. Party#build_account / #create_account raise ExistingAccountError to
    neuter has_one :account autosave.

Merged parties are not a concern because a merged party row is destroyed.
The legacy merged_from_ids.contains([id]) fallback that re-bound old
guest cookies to the merged-into customer was removed in PR #633 and
must not return.

Parameters:

  • customer_id (Integer)

    the id from session[:guest_user_id]

Returns:

  • (Customer, nil)

    the guest customer, or nil when it is missing or
    account-bound



119
120
121
122
123
124
125
126
# File 'app/concerns/controllers/authenticable.rb', line 119

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)
          .where.missing(:account)
          .select_append(email_sub_query)
          .first || warn_on_session_guest_id_leak(customer_id)
end

#logging_in(cur_account, guest_user) ⇒ void

This method returns an undefined value.

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

Parameters:

  • cur_account (Account, nil)

    the account being signed in

  • guest_user (Customer, nil)

    the guest customer to hand off from



181
182
183
184
185
186
187
188
189
190
191
192
# File 'app/concerns/controllers/authenticable.rb', line 181

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

#resourceAccount

Builds an in-memory Account around the current or guest user's email for
Devise form helpers.

Returns:

  • (Account)

    the (unsaved) account resource



75
76
77
# File 'app/concerns/controllers/authenticable.rb', line 75

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

#resource_nameSymbol

Devise resource name for the account scope.

Returns:

  • (Symbol)

    the Devise resource name



68
69
70
# File 'app/concerns/controllers/authenticable.rb', line 68

def resource_name
  :account
end

#restrict_access_for_non_employeesvoid

This method returns an undefined value.

Blocks non-employee users from viewing records scoped to another
employee via the employee_id param.



288
289
290
291
292
293
# File 'app/concerns/controllers/authenticable.rb', line 288

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_pathString

Return the request path with the magic-link bearer query param 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. auth_token is also stripped defensively for
in-flight email links from the pre-cutover legacy decoder.

Returns:

  • (String)

    the request path with sensitive query params removed



461
462
463
464
# File 'app/concerns/controllers/authenticable.rb', line 461

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

#user_objectHash

Builds the front-end user attributes hash for the current context user,
falling back to a blank object when there is no user or on error.

Returns:

  • (Hash)

    the user attributes exposed to JavaScript



231
232
233
234
235
236
237
238
# File 'app/concerns/controllers/authenticable.rb', line 231

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) ⇒ nil

Telemetry for the case where session[:guest_user_id] points to a row that
exists but already has a persisted account attached. We refuse to re-bind
the session to it (see load_context_user docstring) and mint a fresh
guest instead. Surfaces as an AppSignal warning so leaked cookies are
observable in production logs.

Parameters:

  • customer_id (Integer)

    the rejected session guest user id

Returns:

  • (nil)

    always nil so callers can fall through to guest creation



136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'app/concerns/controllers/authenticable.rb', line 136

def warn_on_session_guest_id_leak(customer_id)
  customer = Customer.find_by(id: customer_id)
  return nil unless customer
  # Customer exists but doesn't match the filter; only warn for the
  # account-attached case (the actual security event). State alone is
  # incidental — promotion-without-account is the normal funnel.
  return nil if customer..blank?

  # 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]=#{customer.id} resolves to account-bound party (state=#{customer.state.inspect}); minting fresh guest"
  logger.warn msg
  ErrorReporting.warning(msg, leaked_party_id: customer.id, leaked_state: customer.state)
  nil
end