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
-
#access_denied ⇒ void
Redirect as appropriate when an access request fails.
-
#authenticate_account(options = {}) ⇒ void
Render the sign-in prompt flow for a guest visitor.
-
#authenticate_account!(options = {}) ⇒ void
Authenticate the current account, bouncing guests to the sign-in page.
-
#authenticate_account_from_login_token! ⇒ Boolean
Sign the account in from a Rails-signed
?login_token=param. -
#check_is_a_manager ⇒ void
Restricts the action to manager accounts.
-
#check_is_a_sales_manager ⇒ void
Restricts the action to sales manager accounts.
-
#check_is_an_admin ⇒ void
Restricts the action to admin accounts.
-
#check_is_an_employee ⇒ void
Restricts the action to employee accounts.
-
#check_party ⇒ void
Sets @party from the session-selected online customer party when the context user is allowed to act for it; clears the session key otherwise.
-
#clear_mismatched_guest_user ⇒ void
If there is a current user and a guest user id which doesn't match we clear things up.
-
#create_guest_user ⇒ Customer
Persists a new guest customer for the session and makes it the current scope user.
-
#credentials? ⇒ Boolean
Whether the context user has credentials, i.e.
-
#current_or_guest_user ⇒ Customer
if user is logged in, return current_user, else return guest_user.
-
#current_or_guest_user_id_read_only ⇒ Integer?
Returns the id of the current user or the session's guest user without creating a guest or otherwise mutating state.
-
#current_user ⇒ Party?
Accesses the current LOGGED IN user from the session.
-
#devise_mapping ⇒ Devise::Mapping
Devise mapping for the account scope.
-
#fully_logged_in? ⇒ Boolean
Whether the visitor is signed in with a real account (not just a guest).
-
#generate_bot_id ⇒ String
Generate a bot id comprising the agent and locale of the request params.
-
#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.
-
#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.
-
#init_current_user ⇒ void
Resolves the current or guest user for the request and wires request-wide state (CurrentScope, PaperTrail whodunnit, AppSignal tagging).
-
#initialize_guest ⇒ Customer
Builds (but does not persist) a new guest customer for the request, using a bot identity for crawlers or a generated name for humans.
-
#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.
-
#logging_in(cur_account, guest_user) ⇒ void
called (once) when the user logs in, insert any code your application needs to hand off from guest_user to current_user.
-
#resource ⇒ Account
Builds an in-memory Account around the current or guest user's email for Devise form helpers.
-
#resource_name ⇒ Symbol
Devise resource name for the account scope.
-
#restrict_access_for_non_employees ⇒ void
Blocks non-employee users from viewing records scoped to another employee via the employee_id param.
-
#scrubbed_request_path ⇒ String
Return the request path with the magic-link bearer query param removed.
-
#user_object ⇒ Hash
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.
-
#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.
Instance Method Details
#access_denied ⇒ void
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 current_account 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 new_account_session_path(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.
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 authenticate_account( = {}) Rails.logger.debug { "authenticate_account: options: #{.inspect}" } account_email = CGI.unescape(params[:account_email] || [:account_email] || '') Rails.logger.debug { "authenticate_account: account_email: #{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 = [: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 new_account_session_path(devise_return_path: devise_return_path, login: account_email) } format.pdf { redirect_to new_account_session_path(devise_return_path: devise_return_path, login: account_email) } 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.
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!( = {}) # 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 authenticate_account_from_login_token! if current_account super else authenticate_account 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.
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 authenticate_account_from_login_token! return false if params[:login_token].blank? account = Account.find_by_token_for(:magic_login, params[:login_token]) || Account.find_signed(params[:login_token], purpose: :magic_login) return false unless account return handle_disabled_account(account) || true if account.disabled? resolve_existing_session_conflict_for(account) logger.debug "authenticate_account_from_login_token! signing in account #{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 = sign_in(:account, 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_manager ⇒ void
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 current_account&.is_manager? access_denied && return end |
#check_is_a_sales_manager ⇒ void
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 current_account&.is_sales_manager? access_denied && return end |
#check_is_an_admin ⇒ void
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 current_account&.is_admin? end |
#check_is_an_employee ⇒ void
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 current_account&.is_employee? access_denied && return end |
#check_party ⇒ void
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_user ⇒ void
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(current_account, tmp_guest_user) if tmp_guest_user end session[:guest_user_id] = nil end |
#create_guest_user ⇒ Customer
Persists a new guest customer for the session and makes it the current
scope user.
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.
468 469 470 |
# File 'app/concerns/controllers/authenticable.rb', line 468 def credentials? @context_user.try(:account).present? end |
#current_or_guest_user ⇒ Customer
if user is logged in, return current_user, else return guest_user
23 24 25 26 27 28 29 30 |
# File 'app/concerns/controllers/authenticable.rb', line 23 def current_or_guest_user if current_account clear_mismatched_guest_user current_user else guest_user end end |
#current_or_guest_user_id_read_only ⇒ Integer?
Returns the id of the current user or the session's guest user without
creating a guest or otherwise mutating state.
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_user ⇒ Party?
Accesses the current LOGGED IN user from the session.
224 225 226 |
# File 'app/concerns/controllers/authenticable.rb', line 224 def current_user current_account.try(:party) end |
#devise_mapping ⇒ Devise::Mapping
Devise mapping for the account scope.
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).
62 63 64 |
# File 'app/concerns/controllers/authenticable.rb', line 62 def fully_logged_in? current_account.present? end |
#generate_bot_id ⇒ String
Generate a bot id comprising the agent and locale of the request params
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
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.
56 57 58 |
# File 'app/concerns/controllers/authenticable.rb', line 56 def identifiable? current_account || (session[:guest_user_id].present? && Customer.where(id: session[:guest_user_id]).present?) end |
#init_current_user ⇒ void
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_guest ⇒ Customer
Builds (but does not persist) a new guest customer for the request,
using a bot identity for crawlers or a generated name for humans.
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:
- Here, load_context_user filters out anything with an account.
- CustomerSessionsController#registration_would_clobber_existing_identity?
refuses register POSTs when the existing party has any persisted
account (pinned by IdentityBindingSafetyTest). - 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.
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.
181 182 183 184 185 186 187 188 189 190 191 192 |
# File 'app/concerns/controllers/authenticable.rb', line 181 def logging_in(cur_account, guest_user) return if cur_account.nil? || guest_user.nil? || cur_account.party_id == guest_user.id if current_account.party logger.debug "logging_in called, current_account.party.id: #{current_account.party.id}" transfer_cart_from_guest(guest_user) transfer_opportunities_from_guest(guest_user) transfer_room_plans_from_guest(guest_user) else attach_guest_to_account(guest_user) end end |
#resource ⇒ Account
Builds an in-memory Account around the current or guest user's email for
Devise form helpers.
75 76 77 |
# File 'app/concerns/controllers/authenticable.rb', line 75 def resource @resource ||= Account.new(email: current_or_guest_user.email) end |
#resource_name ⇒ Symbol
Devise resource name for the account scope.
68 69 70 |
# File 'app/concerns/controllers/authenticable.rb', line 68 def resource_name :account end |
#restrict_access_for_non_employees ⇒ void
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] && !current_account.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_path ⇒ String
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.
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_object ⇒ Hash
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.
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.
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.account.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 |