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.



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

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 = {}) ⇒ Object



286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'app/concerns/controllers/authenticable.rb', line 286

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 = {}) ⇒ Object



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

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!Object

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.



343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
# File 'app/concerns/controllers/authenticable.rb', line 343

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_managerObject



208
209
210
211
212
# File 'app/concerns/controllers/authenticable.rb', line 208

def check_is_a_manager
  return if &.is_manager?

  access_denied && return
end

#check_is_a_sales_managerObject



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

def check_is_a_sales_manager
  return if &.is_sales_manager?

  access_denied && return
end

#check_is_an_adminObject



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

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

#check_is_an_employeeObject



202
203
204
205
206
# File 'app/concerns/controllers/authenticable.rb', line 202

def check_is_an_employee
  return if &.is_employee?

  access_denied && return
end

#check_partyObject



224
225
226
227
228
229
230
231
232
# File 'app/concerns/controllers/authenticable.rb', line 224

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



317
318
319
320
321
322
323
324
325
# File 'app/concerns/controllers/authenticable.rb', line 317

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

Returns:

  • (Boolean)


387
388
389
# File 'app/concerns/controllers/authenticable.rb', line 387

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.



187
188
189
# File 'app/concerns/controllers/authenticable.rb', line 187

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



129
130
131
# File 'app/concerns/controllers/authenticable.rb', line 129

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



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

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



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'app/concerns/controllers/authenticable.rb', line 161

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



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

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.

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.

IMPORTANT: 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.



97
98
99
100
101
102
103
104
# File 'app/concerns/controllers/authenticable.rb', line 97

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

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



148
149
150
151
152
153
154
155
156
157
158
159
# File 'app/concerns/controllers/authenticable.rb', line 148

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



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

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 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.



382
383
384
385
# File 'app/concerns/controllers/authenticable.rb', line 382

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



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

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 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.



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'app/concerns/controllers/authenticable.rb', line 111

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 unless customer..present?

  # 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