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 ⇒ Object
Redirect as appropriate when an access request fails.
- #authenticate_account(options = {}) ⇒ Object
- #authenticate_account!(options = {}) ⇒ Object
-
#authenticate_account_from_login_token! ⇒ Object
Sign the account in from a Rails-signed
?login_token=param. - #authenticate_account_from_token! ⇒ Object
- #check_is_a_manager ⇒ Object
- #check_is_a_sales_manager ⇒ Object
- #check_is_an_admin ⇒ Object
- #check_is_an_employee ⇒ Object
- #check_party ⇒ Object
-
#clear_mismatched_guest_user ⇒ Object
If there is a current user and a guest user id which doesn't match we clear things up.
- #create_guest_user ⇒ Object
- #credentials? ⇒ Boolean
-
#current_or_guest_user ⇒ Object
if user is logged in, return current_user, else return guest_user.
- #current_or_guest_user_id_read_only ⇒ Object
-
#current_user ⇒ Object
Accesses the current LOGGED IN user from the session.
- #devise_mapping ⇒ Object
- #fully_logged_in? ⇒ Boolean
-
#generate_bot_id ⇒ Object
Generate a bot id comprising the agent and locale of the request params.
-
#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.
- #identifiable? ⇒ Boolean
- #init_current_user ⇒ Object
- #initialize_guest ⇒ Object
-
#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.
-
#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.
- #resource ⇒ Object
- #resource_name ⇒ Object
- #restrict_access_for_non_employees ⇒ Object
-
#scrubbed_request_path ⇒ Object
Return the request path with bearer-token query params removed.
- #user_object ⇒ Object
-
#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).
Instance Method Details
#access_denied ⇒ Object
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 current_account 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 new_account_session_path(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 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}" } fall_back_path = request.get? ? request.fullpath : 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 = {}) ⇒ 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!( = {}) # 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 authenticate_account_from_login_token! || authenticate_account_from_token! if current_account super else authenticate_account 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 authenticate_account_from_login_token! return false unless params[:login_token].present? account = Account.find_signed(params[:login_token], purpose: :magic_login) return false unless account return handle_disabled_account(account) || true if account.disabled? existing_session_conflict?(account) && (return true) 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 |
#authenticate_account_from_token! ⇒ Object
357 358 359 360 361 362 363 364 365 |
# File 'app/concerns/controllers/authenticable.rb', line 357 def authenticate_account_from_token! return false unless params[:auth_token].present? && (params[:account_email].present? || params[:account_login].present?) account = find_account_from_token_params return false unless account existing_session_conflict?(account) && (return true) validate_and_sign_in_with_token(account) end |
#check_is_a_manager ⇒ Object
194 195 196 197 198 |
# File 'app/concerns/controllers/authenticable.rb', line 194 def check_is_a_manager return if current_account&.is_manager? access_denied && return end |
#check_is_a_sales_manager ⇒ Object
200 201 202 203 204 |
# File 'app/concerns/controllers/authenticable.rb', line 200 def check_is_a_sales_manager return if current_account&.is_sales_manager? access_denied && return end |
#check_is_an_admin ⇒ Object
206 207 208 |
# File 'app/concerns/controllers/authenticable.rb', line 206 def check_is_an_admin access_denied && return unless current_account&.is_admin? end |
#check_is_an_employee ⇒ Object
188 189 190 191 192 |
# File 'app/concerns/controllers/authenticable.rb', line 188 def check_is_an_employee return if current_account&.is_employee? access_denied && return end |
#check_party ⇒ Object
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_user ⇒ Object
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(current_account, tmp_guest_user) if tmp_guest_user end session[:guest_user_id] = nil end |
#create_guest_user ⇒ Object
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
367 368 369 |
# File 'app/concerns/controllers/authenticable.rb', line 367 def credentials? @context_user.try(:account).present? end |
#current_or_guest_user ⇒ Object
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 current_account clear_mismatched_guest_user current_user else guest_user end end |
#current_or_guest_user_id_read_only ⇒ Object
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_user ⇒ Object
Accesses the current LOGGED IN user from the session.
173 174 175 |
# File 'app/concerns/controllers/authenticable.rb', line 173 def current_user current_account.try(:party) end |
#devise_mapping ⇒ Object
63 64 65 |
# File 'app/concerns/controllers/authenticable.rb', line 63 def devise_mapping @devise_mapping ||= Devise.mappings[:account] end |
#fully_logged_in? ⇒ Boolean
51 52 53 |
# File 'app/concerns/controllers/authenticable.rb', line 51 def fully_logged_in? current_account.present? end |
#generate_bot_id ⇒ Object
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
47 48 49 |
# File 'app/concerns/controllers/authenticable.rb', line 47 def identifiable? current_account || (session[:guest_user_id].present? && Customer.where(id: session[:guest_user_id]).present?) end |
#init_current_user ⇒ Object
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_guest ⇒ Object
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:
-
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. -
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(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 ⇒ Object
59 60 61 |
# File 'app/concerns/controllers/authenticable.rb', line 59 def resource @resource ||= Account.new(email: current_or_guest_user.email) end |
#resource_name ⇒ Object
55 56 57 |
# File 'app/concerns/controllers/authenticable.rb', line 55 def resource_name :account end |
#restrict_access_for_non_employees ⇒ Object
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] && !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 ⇒ Object
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_object ⇒ Object
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 |