Class: CamaleonCms::Admin::SessionsController

Inherits:
CamaleonController show all
Defined in:
app/controllers/camaleon_cms/admin/sessions_controller.rb

Constant Summary collapse

TIMING_EQUALIZER_DIGEST =

A precomputed bcrypt digest spent only to equalize login timing when the username does not exist, so a missing username is not distinguishable from a wrong password by response time (audit finding M14: username enumeration by login timing). Computed once at load, at this environment's cost.

BCrypt::Password.create('cama-login-timing-equalizer').to_s.freeze
PASSWORD_RESET_EMAIL_COOLDOWN =

Minimum interval between password-reset emails to the same account, so the forgot-password endpoint cannot be used to flood a known address (audit finding M13). Within the window a reset request is accepted (and answered identically) but sends no new email; the previously issued token stays valid for its 2h lifetime (see #forgot).

5.minutes

Constants included from UploaderImageProcessing

UploaderImageProcessing::SVG_EXT_PATTERN

Constants included from UploaderContentSecurity

UploaderContentSecurity::COMPRESSED_MARKUP_EXTENSIONS, UploaderContentSecurity::GZIP_MAGIC, UploaderContentSecurity::HTML_MODE_EXTENSIONS, UploaderContentSecurity::MARKUP_EXTENSIONS, UploaderContentSecurity::MAX_DECOMPRESSED_MARKUP_BYTES, UploaderContentSecurity::SCRIPT_EXTENSIONS

Constants included from CaptchaImageGeneration

CaptchaImageGeneration::CAPTCHA_DEFAULT_LENGTH, CaptchaImageGeneration::CAPTCHA_MAX_LENGTH, CaptchaImageGeneration::CAPTCHA_MIN_LENGTH

Constants included from CaptchaHelper

CaptchaHelper::CAMA_ATTACK_WINDOW

Instance Method Summary collapse

Methods inherited from CamaleonController

#captcha, #render_error

Methods included from UploaderSupport

#cama_file_path_to_url, #cama_uploader, #cama_url_to_file_path, #slugify, #slugify_folder, #uploader_verify_name

Methods included from UploaderImageProcessing

#cama_crop_image, #cama_resize_and_crop, #cama_resize_upload, #cama_uploader_generate_thumbnail

Methods included from UploaderPipeline

#cama_tmp_upload, #cama_uploader_ct, #cama_uploader_human_size, #cama_uploader_t, #upload_file

Methods included from UploaderPathSecurity

#cama_allowed_upload_roots, #cama_base64_decoded_size, #cama_canonical_upload_path, #cama_extra_upload_roots, #cama_private_upload_mode?, #cama_private_upload_root, #cama_purge_staged_file, #cama_upload_failure, #path_within?, #same_host?, #same_site_url?, #site_url_path, #strip_locale_prefix

Methods included from UploaderContentSecurity

#cama_trusted_for_unfiltered_upload?, #content_unsafe?, #file_content_unsafe?, #svg_upload?

Methods included from CaptchaImageGeneration

#cama_captcha_build

Methods included from RuntimeAdminMenuConcern

#admin_menu_add_menu, #admin_menu_append_menu_item, #admin_menu_insert_menu_after, #admin_menu_insert_menu_before, #admin_menu_prepend_menu_item, #admin_menus_add_commons, #cama_comments_get_common_data

Methods included from RuntimeHtmlContentConcern

#append_asset_content, #append_asset_libraries, #append_pre_asset_content, #breadcrumb_add, #cama_draw_custom_assets, #cama_draw_pre_asset_contents, #cama_html_helpers_init, #cama_load_libraries, #theme_init

Methods included from ContentHelper

#cama_content_after_draw, #cama_content_append, #cama_content_before_draw, #cama_content_init, #cama_content_prepend

Methods included from RuntimeShortcodeThemeConcern

#cama_shortcode_data, #cama_shortcode_model_parser, #resolve_shortcode_theme_asset, #shortcode_add, #shortcode_asset_reference, #shortcode_descriptions, #shortcode_keys, #shortcode_templates, #shortcodes_init

Methods included from ThemeHelper

#self_theme_key, #theme_asset_file_path, #theme_asset_path, #theme_asset_url, #theme_home_page, #theme_init, #theme_layout, #theme_view

Methods included from HookLifecycleConcern

#hook_run, #hook_skip, #hooks_run

Methods included from RequestContextConcern

#current_site

Methods included from SiteHelper

#cama_current_site_host_port, #cama_get_list_layouts_files, #cama_get_list_template_files, #cama_is_test_request?, #current_locale, #current_site, #current_theme, #site_after_install, #site_install_theme, #site_uninstall_theme

Methods included from SessionRuntimeConcern

#auth_session_error, #cama_register_user, #login_user_with_password

Methods included from CaptchaHelper

#cama_captcha_attack_ip_count, #cama_captcha_attack_ip_key, #cama_captcha_increment_attack, #cama_captcha_reset_attack, #cama_captcha_tag, #cama_captcha_tags_if_under_attack, #cama_captcha_total_attacks, #cama_captcha_under_attack?, #cama_captcha_verified?, #captcha_verify_if_under_attack

Methods included from EmailHelper

#cama_send_email, #cama_send_mail_to_admin, #send_email, #send_password_reset_email, #send_user_confirm_email

Methods included from HooksHelper

#hook_run, #hook_skip, #hooks_run

Methods included from PluginsHelper

#current_plugin, #plugin_asset_path, #plugin_asset_url, #plugin_destroy, #plugin_install, #plugin_layout, #plugin_load_helpers, #plugin_uninstall, #plugin_upgrade, #plugin_view, #plugins_initialize, #self_plugin_key

Methods included from SessionHelper

#cama_authenticate, #cama_current_role, #cama_current_user, #cama_get_session_id, #cama_impersonation_parent_user, #cama_logout_user, #cama_on_heroku?, #cama_register_user, #cama_sign_in?, #cookie_auth_token_complete?, #cookie_split_auth_token, #login_user, #login_user_with_password, #session_back_to_parent, #session_switch_user, #user_auth_token_from_cookie

Methods included from CamaleonHelper

#cama_cache_fetch, #cama_edit_link, #cama_is_admin_request?, #cama_pluralize_text, #cama_sitemap_cats_generator, #cama_t, #ct

Instance Method Details

#back_to_parentObject

Re-authenticate the impersonating admin before restoring their session (H6 residual). Reachable only while an impersonation is active; a GET renders the confirmation form, a POST checks the admin's password and only then returns to the parent session.



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'app/controllers/camaleon_cms/admin/sessions_controller.rb', line 114

def back_to_parent
  return redirect_to() unless session[:parent_auth_token].present? && cama_sign_in?

  @parent_user = cama_impersonation_parent_user
  # A stash that no longer resolves to a user — or resolves to an account with no password
  # digest, which #authenticate would raise on and which cannot prove the holder is the admin —
  # cannot be returned to; fail closed and end the session.
  return cama_logout_user if @parent_user.blank? || @parent_user.password_digest.blank?

  @impersonated_user = cama_current_user
  if request.post?
    return if ('back_to_parent')

    # Failed guesses feed the login form's attack counter (same 'login' key), so this endpoint is
    # not an unthrottled oracle for the admin password: past the threshold a captcha is also
    # required, and because the parent stash lives in this same session, resetting the counter
    # (new session / full logout) destroys the stash being attacked.
    captcha_validate = captcha_verify_if_under_attack('login')
    if captcha_validate && @parent_user.authenticate(params[:password].to_s)
      cama_captcha_reset_attack('login')
      return session_back_to_parent(cama_admin_dashboard_path)
    end

    cama_captcha_increment_attack('login')
    flash.now[:error] = if captcha_validate
                          t('camaleon_cms.admin.login.message.reauth_failed',
                            default: 'Incorrect password. Please try again.')
                        else
                          t('camaleon_cms.admin.login.message.invalid_caption')
                        end
  end
  render 'back_to_parent'
end

#confirm_emailObject



249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'app/controllers/camaleon_cms/admin/sessions_controller.rb', line 249

def confirm_email
  @user = current_site.users.new
  if params[:h]
    @user = current_site.users.where(confirm_email_token: params[:h]).first
    if @user.nil?
      flash[:error] = t('camaleon_cms.admin.login.message.confirm_email_token_incorrect')
    elsif @user.confirm_email_sent_at.nil? || @user.confirm_email_sent_at < 2.hours.ago
      flash[:error] = t('camaleon_cms.admin.login.message.confirm_email_token_expired')
    else
      flash[:notice] = t('camaleon_cms.admin.login.message.confirm_email_success')
      @user.is_valid_email = true
      @user.save!
      # Security (audit M16): single-use, like the password-reset token -- clear it so the same
      # confirmation link cannot be replayed.
      @user.update_columns(confirm_email_token: nil, confirm_email_sent_at: nil) # rubocop:disable Rails/SkipsModelValidations
    end
  end
  redirect_to 
end

#forgotObject



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'app/controllers/camaleon_cms/admin/sessions_controller.rb', line 148

def forgot
  @user = current_site.users.new
  # get form reset password
  if params[:h].present?
    # Look up by the DB column (a `where` clause is never shadowed by the Rails 7.1+
    # has_secure_password method), scoped to the current site so a token cannot be redeemed
    # against a site that does not own the account.
    @user = current_site.users.where(password_reset_token: params[:h]).first
    if @user.nil?
      flash[:error] = t('camaleon_cms.admin.login.message.forgot_url_incorrect')
      return redirect_to cama_admin_forgot_path
    end
    # nil timestamp (or one past the window) is expired, not a NoMethodError.
    if @user.password_reset_sent_at.blank? || @user.password_reset_sent_at < 2.hours.ago
      flash[:error] = t('camaleon_cms.admin.login.message.forgot_expired')
      return redirect_to 
    end

    if params[:user].present?
      # Blank-check the *permitted* password: permit drops a non-scalar value (e.g.
      # `user[password][]=x`), and has_secure_password only validates presence on create —
      # either way `update` would be a silent no-op reported as success, consuming the token.
      # A scalar `user` param (`?user=foo`) has no .permit: treat it as an empty submission.
      reset_params = params[:user].try(:permit, :password, :password_confirmation) || {}
      if reset_params[:password].blank?
        flash[:error] = t('camaleon_cms.admin.login.message.reset_password_error')
      elsif @user.update(reset_params)
        # Single-use: clear the token so the same link cannot be replayed.
        @user.update_columns(password_reset_token: nil, password_reset_sent_at: nil) # rubocop:disable Rails/SkipsModelValidations
        flash[:notice] = t('camaleon_cms.admin.login.message.reset_password_succes')
        return redirect_to 
      else
        flash[:error] = t('camaleon_cms.admin.login.message.reset_password_error')
      end
    end
    @form_reset = true
    return render 'forgot'
  end

  # TODO: Move this out of the controller
  # send email reset password
  return if params[:user].blank?

  data_user = user_permit_data
  # Custom class finder: emails are stored downcased, so the lookup must
  # lower-case both sides regardless of DB collation.
  @user = current_site.users.find_by_email(data_user[:email]) # rubocop:disable Rails/DynamicFindBy
  # Security (audit 2026-08-11 M13): do not reveal whether an account exists, and do not let the
  # endpoint mail-bomb a known address. Send at most one reset email per cooldown window, and
  # always answer with the same neutral message whether or not the email matched an account.
  send_password_reset_email(@user) if @user.present? && cama_password_reset_email_allowed?(@user)
  # Only en.yml carries this key while the process locale follows the current admin/site
  # language (audit M15) -- fall back to English rather than emit "translation missing".
  flash[:notice] = t('camaleon_cms.admin.login.message.password_reset_requested',
                     default: t('camaleon_cms.admin.login.message.password_reset_requested', locale: :en))
  redirect_to 
end

#loginObject

you can pass return_to as a param (mysite.com/admin/login?return_to=my-url) and this will be used after user logged in



33
34
35
36
37
38
39
40
# File 'app/controllers/camaleon_cms/admin/sessions_controller.rb', line 33

def 
  return cama_safe_redirect(params[:return_to], cama_admin_dashboard_path) if signin?

  cookies[:return_to] = params[:return_to] if params[:return_to].present?
  @user ||= current_site.users.new

  render 'login'
end

#login_postObject



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'app/controllers/camaleon_cms/admin/sessions_controller.rb', line 42

def 
  return if ('login')

  data_user = user_permit_data
  # Custom class finder (not the dynamic finder): usernames are stored downcased,
  # so the lookup must lower-case both sides regardless of DB collation.
  @user = current_site.users.find_by_username(data_user[:username]) # rubocop:disable Rails/DynamicFindBy
  captcha_validate = captcha_verify_if_under_attack('login')
  r = { user: @user, params: params, password: data_user[:password], captcha_validate: captcha_validate,
        stop_process: false }
  hooks_run('user_before_login', r)
  return if r[:stop_process] # permit to redirect for data completion

  if captcha_validate && cama_password_matches?(@user, data_user[:password])
    # Email validation if is necessary
    if @user.is_valid_email? || !current_site.need_validate_email?
      cama_captcha_reset_attack('login')
      r = { user: @user, redirect_to: params[:format] == 'json' ? false : nil }
      hooks_run('after_login', r)
      # An after_login hook may set r[:allow_external_redirect] to vouch for an off-site r[:redirect_to]
      # (SSO/payment); without it the destination is same-host/allowlist-only. See login_user.
      (@user, params[:remember_me].present?, r[:redirect_to],
                 allow_external: r[:allow_external_redirect].present?)
      render(json: flash.discard.to_hash) if params[:format] == 'json'
      return
    else
      flash[:error] = t('camaleon_cms.admin.login.message.email_not_validated')
      @user = current_site.users.new(data_user)
       if params[:format] != 'json'
    end
  else
    cama_captcha_increment_attack('login')
    flash[:error] = if captcha_validate
                      t('camaleon_cms.admin.login.message.fail')
                    else
                      t('camaleon_cms.admin.login.message.invalid_caption')
                    end
    @user = current_site.users.new(data_user)
     if params[:format] != 'json'
  end
  render(json: flash.discard.to_hash) if params[:format] == 'json'
end

#logoutObject



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'app/controllers/camaleon_cms/admin/sessions_controller.rb', line 85

def logout
  # A request that is no longer authenticated has nothing to confirm and nothing a forged GET
  # could end -- but it may still carry session leftovers (a stale impersonation stash, H6),
  # so it goes through cama_logout_user's cleanup on any verb, exactly as before.
  return cama_logout_user unless cama_sign_in?

  # While impersonating, the ordinary Logout link must not silently hand the admin account back
  # to whoever holds the session — returning to the parent now requires the admin's password
  # (see #back_to_parent). `?full=1` forces a real logout of the impersonated session instead.
  if session[:parent_auth_token].present? && params[:full].blank?
    redirect_to cama_admin_back_to_parent_path
  elsif request.post?
    cama_logout_user
  else
    # Security (audit M6): logging out changes state, so only the POST above performs it —
    # keyed on request.post?, not !request.get?, because Rails exempts HEAD from CSRF exactly
    # like GET. The GET renders a confirmation instead of 404ing: frontend themes across the
    # ecosystem link this path, and their visitors get one extra click, not a broken link.
    prepare_logout_confirmation
    # Force the HTML template/layout: the confirmation is HTML-only, so a non-HTML GET (an ajax
    # `/admin/logout.json`) renders the same confirmation instead of raising MissingTemplate (500),
    # still leaving the session untouched.
    render :logout_confirm, formats: [:html], content_type: 'text/html'
  end
end

#registerObject



206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'app/controllers/camaleon_cms/admin/sessions_controller.rb', line 206

def register
  @user ||= current_site.users.new
  # A scalar `user` param has no .permit — only a form-shaped submission enters this branch.
  if params[:user].respond_to?(:permit)
    params[:user][:role] = PluginRoutes.system_info['default_user_role']
    params[:user][:is_valid_email] = false if current_site.need_validate_email?
    user_data = user_permit_data
    result = cama_register_user(user_data, nil)
    if result[:result] == false && result[:type] == :captcha_error
      @user.errors.add(:captcha, result[:message])
      render 'register'
    elsif result[:result] == false && result[:type] == :stopped
      # A vetoing user_before_register handler may take over the response itself (render/redirect,
      # the user_before_login convention) or add its own errors to r[:user]; rendering again would
      # raise DoubleRenderError, so re-render only when it did not — and show a generic reason so the
      # visitor is not left with a silently re-rendered form.
      unless performed?
        if @user.errors.empty?
          @user.errors.add(:base, t('camaleon_cms.admin.users.message.registration_stopped',
                                    default: 'Registration could not be completed.'))
        end
        render 'register'
      end
    elsif result[:result]
      @user = result[:user] if result[:user].present?
      flash[:notice] = result[:message]
      send_user_confirm_email(@user) if current_site.need_validate_email?
      r = { user: @user, redirect_url: result[:redirect_url] }
      hooks_run('user_registered', r)
      # Host-check the post-registration destination for the same reason login_user host-checks its
      # explicit redirect_url: the user_registered hook is caller-controlled (downstream plugins), so
      # an off-site value falls back to the safe default unless the hook vouches for it via
      # r[:allow_external_redirect] (or its host is allowlisted).
      cama_safe_redirect(r[:redirect_url], ,
                         allow_external: r[:allow_external_redirect].present?)
    else
      render 'register'
    end
  else
    render 'register'
  end
end