From c077628084c42405c02a129080602dfdf0fa877b Mon Sep 17 00:00:00 2001 From: George Byers Date: Wed, 26 Feb 2025 18:35:30 -0500 Subject: [PATCH] Using ActionCable to poll the backend via websockets to determine user activity and extend or cancel the session --- .../channels/application_cable/connection.rb | 16 +- app/app/channels/session_channel.rb | 197 ++++++++++++------ .../controllers/api/sessions_controller.rb | 76 ------- app/app/controllers/application_controller.rb | 5 + .../cbv/sessions/timeout_controller.js | 180 +++++++++++----- .../javascript/utilities/sessionService.js | 43 ++-- .../cbv/sessions/_timeout_modal.html.erb | 14 +- app/config/locales/en.yml | 12 +- app/config/routes.rb | 3 - 9 files changed, 313 insertions(+), 233 deletions(-) delete mode 100644 app/app/controllers/api/sessions_controller.rb diff --git a/app/app/channels/application_cable/connection.rb b/app/app/channels/application_cable/connection.rb index 6e65da67..3598b678 100644 --- a/app/app/channels/application_cable/connection.rb +++ b/app/app/channels/application_cable/connection.rb @@ -1,25 +1,13 @@ module ApplicationCable class Connection < ActionCable::Connection::Base - identified_by :current_user, :session_id + identified_by :session_id def connect - self.current_user = find_verified_user self.session_id = session.id - logger.add_tags "ActionCable", current_user&.id || "Guest-#{session_id}" - end + self.current_user = find_verified_user def session @request.session end - - private - - def find_verified_user - if verified_user = env['warden'].user - verified_user - else - nil - end - end end end diff --git a/app/app/channels/session_channel.rb b/app/app/channels/session_channel.rb index b67a0966..9e0a3aff 100644 --- a/app/app/channels/session_channel.rb +++ b/app/app/channels/session_channel.rb @@ -1,88 +1,169 @@ class SessionChannel < ApplicationCable::Channel - # Check session activity every minute - periodically :check_session_activity, every: 60.seconds + # Check session activity every minute in production, more frequently in development + periodically :check_session_activity, every: Rails.env.development? ? 5.seconds : 60.seconds def subscribed - identifier = current_user || connection.session[:cbv_flow_id] || connection.session.id - stream_for identifier - + @cbv_flow_id = connection.session[:cbv_flow_id] + stream_for current_channel_identifier + # Record connection time connection.instance_variable_set(:@session_channel_connected_at, Time.current) - - # Send session info to client + + # Track this initial connection as an activity + update_last_activity_time + + # Send initial session info transmit_session_info end - + def received(data) - # This is called whenever data is received from the client - # We can use this to track user activity at the websocket level - Rails.logger.debug "Data received on SessionChannel: #{data}" + # This method is called for general messages received from the client + # that don't match a specific method name in perform('method_name'). + # ActionCable automatically routes messages to methods named in 'perform'. + # Example: Client calls subscription.perform('extend_session') will directly + # call the extend_session method, bypassing this received method. + Rails.logger.debug "Data received on SessionChannel: #{data}" + # Update last activity time - connection.instance_variable_set(:@session_channel_last_activity, Time.current) + update_last_activity_time end - # Send current session information to the client - def transmit_session_info - identifier = current_user || connection.session[:cbv_flow_id] || connection.session.id - - if current_user && current_user.respond_to?(:last_request_at) && current_user.last_request_at - # For authenticated users, use Devise timeout (default 30 minutes) - timeout_in = defined?(Devise) && Devise.respond_to?(:timeout_in) ? Devise.timeout_in : 30.minutes - last_active = current_user.last_request_at || Time.current - time_until_timeout = [timeout_in - (Time.current - last_active), 0].max - - transmit({ - event: "session.info", - timeout_in_ms: timeout_in * 1000, - time_remaining_ms: time_until_timeout * 1000, - last_activity: last_active.iso8601 - }) - elsif connection.session[:cbv_flow_id].present? - # For CBV flow, use 30 minute timeout - transmit({ - event: "session.info", - timeout_in_ms: 30.minutes * 1000, - time_remaining_ms: 30.minutes * 1000, - last_activity: Time.current.iso8601 - }) + def extend_session(data) + update_last_activity_time + + if @cbv_flow_id + connection.session[:last_activity_at] = Time.current.to_i + Rails.logger.debug "Updated session timestamp for CBV flow #{@cbv_flow_id}" end + + # Clear any warning flags since the session has been extended + connection.instance_variable_set(:@warning_sent_at, nil) + + Rails.logger.info "Transmitting session.extended event" + transmit({ + event: "session.extended", + message: "Session extended successfully", + extended_at: Time.current.iso8601 + }) + + # Also update session info with new timestamps + transmit_session_info + + Rails.logger.info "Session successfully extended for #{current_channel_identifier}" + end + + def transmit_session_info + # Get the Devise timeout setting + timeout_in = get_timeout_setting + + # Calculate time remaining + last_active = get_last_activity_time + time_until_timeout = [ timeout_in - (Time.current - last_active), 0 ].max + + transmit({ + event: "session.info", + timeout_in_ms: timeout_in * 1000, + time_remaining_ms: time_until_timeout * 1000, + last_activity: last_active.iso8601 + }) end private - def check_session_activity - identifier = current_user || connection.session[:cbv_flow_id] || connection.session.id - return unless identifier - - # Get last activity time either from the connection or from the user's last_request_at - last_activity = connection.instance_variable_get(:@session_channel_last_activity) + def current_channel_identifier + current_user&.id || @cbv_flow_id || connection.session.id + end + + def get_timeout_setting + # In development, use a shorter timeout for testing (2 minutes) + Rails.env.development? ? 2.minutes : 30.minutes + end + + # Update the last activity timestamp + def update_last_activity_time + current_time = Time.current + connection.instance_variable_set(:@session_channel_last_activity, current_time) - if current_user && current_user.respond_to?(:last_request_at) && current_user.last_request_at - # For authenticated users, use Devise timeout (default 30 minutes) - timeout_in = defined?(Devise) && Devise.respond_to?(:timeout_in) ? Devise.timeout_in : 30.minutes - last_active = [last_activity, current_user.last_request_at].compact.max || Time.current - time_until_timeout = [timeout_in - (Time.current - last_active), 0].max - Rails.logger.debug "User #{current_user.id} session expires in #{time_until_timeout} seconds" - else - # For CBV flow, use session timeout (30 minutes) - timeout_in = 30.minutes - connection_time = connection.instance_variable_get(:@session_channel_connected_at) || Time.current - last_active = last_activity || connection_time - time_until_timeout = [timeout_in - (Time.current - last_active), 0].max + # Also update the session timestamp + if connection.session + connection.session[:last_activity_at] = current_time.to_i end + + Rails.logger.debug "Updated activity time for #{current_channel_identifier} to #{current_time}" + end + + def get_last_activity_time + # Get timestamps from different sources + websocket_activity = connection.instance_variable_get(:@session_channel_last_activity) + connection_time = connection.instance_variable_get(:@session_channel_connected_at) + # Get activity time from the session (if tracked in ApplicationController) + session_activity = connection.session && connection.session[:last_activity_at].present? ? + Time.at(connection.session[:last_activity_at]) : nil + + # Log the available timestamps for debugging + timestamps = { + websocket_activity: websocket_activity, + session_activity: session_activity, + connection_time: connection_time + } + Rails.logger.debug "Available timestamps for #{current_channel_identifier}: #{timestamps.inspect}" + + # Use the most recent timestamp from any source, fallback to current time + [ websocket_activity, session_activity, connection_time, Time.current ].compact.max + end - # Convert to milliseconds for JavaScript + def check_session_activity + Rails.logger.debug "SessionChannel#check_session_activity called at #{Time.current}" + return unless @cbv_flow_id + + # Get the timeout setting + session_timeout_limit = get_timeout_setting + + # Get the most recent activity timestamp + last_active = get_last_activity_time + + # Calculate time until timeout + time_until_timeout = [ session_timeout_limit - (Time.current - last_active), 0 ].max time_until_timeout_ms = time_until_timeout * 1000 + # Log detailed information for debugging + Rails.logger.debug "Session for #{current_channel_identifier}: timeout_limit=#{session_timeout_limit}, last_active=#{last_active}, " + + "current=#{Time.current}, time_until_timeout=#{time_until_timeout}s" + # Send warning when approaching timeout (5 minutes before) warning_threshold = 5.minutes.to_i * 1000 - if time_until_timeout_ms <= warning_threshold && time_until_timeout_ms > 0 - broadcast_to(identifier, { + warning_already_sent = connection.instance_variable_get(:@warning_sent_at) + + # Only send a warning if we're in the warning window AND + # (we haven't sent a warning yet OR the previous warning was sent more than 1 minute ago) + if time_until_timeout_ms <= warning_threshold && time_until_timeout_ms > 0 && + (warning_already_sent.nil? || Time.current - warning_already_sent > 1.minute) + + # Record that we sent a warning + connection.instance_variable_set(:@warning_sent_at, Time.current) + + Rails.logger.info "Sending session timeout warning to #{current_channel_identifier}, #{time_until_timeout_ms / 1000} seconds remaining" + + broadcast_to(current_channel_identifier, { event: "session.warning", message: "Your session will expire soon", time_remaining_ms: time_until_timeout_ms }) end + + # Debug info in development + if Rails.env.development? + broadcast_to(current_channel_identifier, { + event: "session.debug", + message: "Session activity check ran", + time_remaining_ms: time_until_timeout_ms, + last_activity: last_active.iso8601, + timeout_in: session_timeout_limit.to_i, + current_time: Time.current.iso8601, + cbv_flow_id: @cbv_flow_id, + warning_sent_at: connection.instance_variable_get(:@warning_sent_at)&.iso8601 + }) + end end -end \ No newline at end of file +end diff --git a/app/app/controllers/api/sessions_controller.rb b/app/app/controllers/api/sessions_controller.rb deleted file mode 100644 index b9cadd50..00000000 --- a/app/app/controllers/api/sessions_controller.rb +++ /dev/null @@ -1,76 +0,0 @@ -class Api::SessionsController < ApplicationController - # Skip CSRF protection for API calls if you're using protect_from_forgery - # skip_before_action :verify_authenticity_token, only: [:extend] - - def extend - Rails.logger.info "API::SessionsController#extend called" - - # Reset the Devise timeout timer - request.env['devise.skip_timeout'] = true - - if current_user - Rails.logger.info "Current user found: #{current_user.id}" - current_user.remember_me! if current_user.respond_to?(:remember_me!) - current_user.remember_me = true if current_user.respond_to?(:remember_me=) - - # Update the last_request_at timestamp to extend the Devise timeout - if current_user.respond_to?(:update_tracked_fields!) - current_user.update_tracked_fields!(request) - end - - # Force sign in to reset the Devise timeout - sign_in(current_user, force: true) - - respond_to do |format| - format.json { - Rails.logger.info "Responding with JSON" - render json: { success: true, message: "Session extended successfully" } - } - format.html { - Rails.logger.info "Responding with HTML redirect" - redirect_back(fallback_location: root_path) - } - format.any { - Rails.logger.info "Responding with 406 Not Acceptable" - head :not_acceptable - } - end - elsif session[:cbv_flow_id].present? - # Handle CBV flow session extension - Rails.logger.info "Extending session for CBV flow: #{session[:cbv_flow_id]}" - - # Reset the session timestamp - request.env["rack.session.options"][:expire_after] = 30.minutes - - respond_to do |format| - format.json { - Rails.logger.info "Responding with JSON for CBV flow" - render json: { success: true, message: "Session extended successfully" } - } - format.html { - Rails.logger.info "Responding with HTML redirect for CBV flow" - redirect_back(fallback_location: root_path) - } - format.any { - Rails.logger.info "Responding with 406 Not Acceptable for CBV flow" - head :not_acceptable - } - end - else - Rails.logger.warn "No current user or CBV flow found" - respond_to do |format| - format.json { render json: { success: false, error: "No active session" }, status: :unauthorized } - format.html { redirect_to new_user_session_path } - format.any { head :unauthorized } - end - end - rescue => e - Rails.logger.error "Error in extend session: #{e.message}" - Rails.logger.error e.backtrace.join("\n") - respond_to do |format| - format.json { render json: { success: false, error: e.message }, status: :internal_server_error } - format.html { redirect_to root_path, alert: "An error occurred while extending your session." } - format.any { head :internal_server_error } - end - end -end \ No newline at end of file diff --git a/app/app/controllers/application_controller.rb b/app/app/controllers/application_controller.rb index c03ab921..a93f1554 100644 --- a/app/app/controllers/application_controller.rb +++ b/app/app/controllers/application_controller.rb @@ -6,6 +6,7 @@ class ApplicationController < ActionController::Base before_action :redirect_if_maintenance_mode before_action :enable_mini_profiler_in_demo before_action :check_help_param + before_action :track_user_activity rescue_from ActionController::InvalidAuthenticityToken do redirect_to root_url, flash: { slim_alert: { type: "info", message_html: t("cbv.error_missing_token_html") } } @@ -97,4 +98,8 @@ def check_help_param flash.now[:alert_type] = "warning" end end + + def track_user_activity + session[:last_activity_at] = Time.current.to_i + end end diff --git a/app/app/javascript/controllers/cbv/sessions/timeout_controller.js b/app/app/javascript/controllers/cbv/sessions/timeout_controller.js index 5090a768..7f64dfa9 100644 --- a/app/app/javascript/controllers/cbv/sessions/timeout_controller.js +++ b/app/app/javascript/controllers/cbv/sessions/timeout_controller.js @@ -9,72 +9,135 @@ export default class extends Controller { subscription = null; connect() { - console.log('Timeout controller connected') this.setupActionCable() } disconnect() { - console.log('Timeout controller disconnected') if (this.subscription) { this.subscription.unsubscribe() } } setupActionCable() { - console.log('Setting up ActionCable for session management') - this.subscription = this.cable.subscriptions.create({ channel: 'SessionChannel' }, { - connected: () => { - console.log("Connected to SessionChannel") - }, - disconnected: () => { - console.log("Disconnected from SessionChannel") - }, - received: (data) => { - console.log("Received message from SessionChannel:", data) - this.handleChannelMessage(data) - } - }) + console.log('Setting up ActionCable connection') + + try { + this.subscription = this.cable.subscriptions.create( + { channel: 'SessionChannel' }, + { + connected: () => { + console.log('Connected to SessionChannel') + }, + disconnected: () => { + console.log('Disconnected from SessionChannel') + }, + rejected: () => { + console.error('Connection to SessionChannel was rejected') + }, + received: (data) => { + console.log('Received data from SessionChannel:', data) + this.handleChannelMessage(data) + } + } + ) + + // Verify subscription was created + console.log('Subscription created:', this.subscription ? 'Yes' : 'No') + } catch (error) { + console.error('Error setting up ActionCable:', error) + } } handleChannelMessage(data) { - if (data.event === 'session.info') { - console.log('Received session info from server:', data) - - // If session is close to expiring, show warning - const warningThreshold = 5 * 60 * 1000; // 5 minutes - if (data.time_remaining_ms <= warningThreshold) { - console.log('Session close to expiring based on server info, showing warning') + console.log('Received message from session channel:', data) + + switch (data.event) { + case 'session.warning': this.showWarning(data.time_remaining_ms) - } - } else if (data.event === 'session.warning') { - console.log('Session warning received, showing modal') - this.showWarning(data.time_remaining_ms) - } else if (data.event === 'session.error') { - console.error('Session error:', data.message) - - // Re-enable the extend button - if (this.hasExtendButtonTarget) { - this.extendButtonTarget.disabled = false - } + break + + case 'session.extended': + console.log('Session extended successfully') + + // Show success message + const successStatusMessage = document.getElementById('timeout-status-message') + if (successStatusMessage) { + successStatusMessage.textContent = 'Session extended successfully!' + successStatusMessage.classList.remove('is-hidden') + + // Hide message after a few seconds + setTimeout(() => { + successStatusMessage.classList.add('is-hidden') + }, 3000) + } + + // Close the modal after a short delay + setTimeout(() => { + this.closeModal() + }, 1500) + break + + case 'session.info': + console.log('Received session info:', data) + // You can implement additional logic here if needed + break + + case 'session.error': + console.error('Session error:', data.message) + + // Re-enable the extend button + if (this.hasExtendButtonTarget) { + this.extendButtonTarget.disabled = false + } + + // Show error message + const errorStatusMessage = document.getElementById('timeout-status-message') + if (errorStatusMessage) { + errorStatusMessage.textContent = `Error: ${data.message || 'Failed to extend session'}` + errorStatusMessage.classList.remove('is-hidden') + } + break + + case 'session.debug': + console.debug('Session debug message:', data) + break + + default: + console.log('Unknown session event:', data.event) } } showWarning(timeRemainingMs) { - // Set a grace period timer based on remaining time or default to 5 minutes - const gracePeriod = timeRemainingMs || 5 * 60 * 1000; // Use time from server or 5 minutes - console.log(`Setting grace period timer for ${gracePeriod} ms`) + // Check if the modal is already visible before showing it again + const isModalAlreadyVisible = this.isModalVisible() + if (isModalAlreadyVisible) { + console.log('Modal already visible, not showing again') + return + } // Set timeout to redirect after grace period setTimeout(() => { console.log('Grace period expired, timing out the session') this.timeout() - }, gracePeriod) - - if (this.hasTriggerTarget) { - this.triggerTarget.click() - } else { - console.error('Trigger target not found when trying to show warning') + }, timeRemainingMs) + + console.log('Showing warning modal') + this.triggerTarget.click() + } + + // Helper method to check if the modal is visible + isModalVisible() { + if (this.hasModalTarget) { + const modalElement = document.getElementById('cbv-session-timeout-modal') + if (modalElement && window.uswdsModal) { + // USWDS modals have a 'is-hidden' class when they're hidden + return !modalElement.classList.contains('is-hidden') + } else { + // Fallback check if not using USWDS + return !this.modalTarget.classList.contains('is-hidden') + } } + return false } closeModal() { @@ -108,19 +171,36 @@ export default class extends Controller { this.extendButtonTarget.disabled = true } - console.log('Extending session via API call') + console.log('Extending session via ActionCable') + + // Show status message if it exists + const statusMessage = document.getElementById('timeout-status-message') + if (statusMessage) { + statusMessage.textContent = 'Extending session...' + statusMessage.classList.remove('is-hidden') + } - // Use the sessionService utility to extend the session - await extendSession(); - console.log('Session extended successfully') + // Check if we have an active subscription + if (!this.subscription) { + console.error('No active subscription found') + throw new Error('No active subscription found') + } - // Close the modal after a short delay - setTimeout(() => { - this.closeModal() - }, 1500) + // Pass the existing subscription to extendSession + await extendSession(this.subscription) + + // Note: We don't need to handle success here as it will come through + // the ActionCable channel as a 'session.extended' event } catch (error) { console.error('Failed to extend session:', error) + // Show error message + const statusMessage = document.getElementById('timeout-status-message') + if (statusMessage) { + statusMessage.textContent = `Error: ${error.message || 'Failed to extend session'}` + statusMessage.classList.remove('is-hidden') + } + // Re-enable the extend button if (this.hasExtendButtonTarget) { this.extendButtonTarget.disabled = false diff --git a/app/app/javascript/utilities/sessionService.js b/app/app/javascript/utilities/sessionService.js index 298fa97d..2ae27631 100644 --- a/app/app/javascript/utilities/sessionService.js +++ b/app/app/javascript/utilities/sessionService.js @@ -1,24 +1,29 @@ -/** - * Utility functions for session management - */ -import { fetchInternalAPIService } from './fetchInternalAPIService'; +import * as ActionCable from '@rails/actioncable'; /** - * Extends the user's session by making a POST request to the API - * @returns {Promise} The fetch response + * Extends the user's session by sending a message through ActionCable + * @param {Object} existingSubscription - The existing ActionCable subscription (optional) + * @returns {Promise} A promise that resolves when the message is sent */ -export const extendSession = async () => { - const response = await fetchInternalAPIService('/api/extend_session', { - method: 'POST', - headers: { - 'Accept': 'application/json' - }, - credentials: 'same-origin' +export const extendSession = async (existingSubscription = null) => { + return new Promise((resolve, reject) => { + let subscription = existingSubscription; + + if (!subscription) { + // Try to find an existing subscription from the default consumer + const consumer = ActionCable.getConsumer() || ActionCable.createConsumer(); + subscription = consumer.subscriptions.findAll({ + channel: 'SessionChannel' + })[0]; + } + + if (subscription) { + console.log('Using existing subscription to extend session'); + subscription.perform('extend_session', {}); + resolve(); + } else { + console.error('No active SessionChannel subscription found'); + reject(new Error('No active SessionChannel subscription found')); + } }); - - if (!response.ok) { - throw new Error(`Failed to extend session: ${response.status}`); - } - - return response; }; \ No newline at end of file diff --git a/app/app/views/cbv/sessions/_timeout_modal.html.erb b/app/app/views/cbv/sessions/_timeout_modal.html.erb index 8429bfc9..c3d2d1ea 100644 --- a/app/app/views/cbv/sessions/_timeout_modal.html.erb +++ b/app/app/views/cbv/sessions/_timeout_modal.html.erb @@ -2,25 +2,25 @@

- <%= t('session_timeout.modal.heading') %> + <%= t("session_timeout.modal.heading") %>

- <%= t('session_timeout.modal.description') %> + <%= t("session_timeout.modal.description") %>

- +
-<%= link_to t('session_timeout.modal.trigger'), "#cbv-session-timeout-modal", { +<%= link_to t("session_timeout.modal.trigger"), "#cbv-session-timeout-modal", { aria: { controls: "cbv-session-timeout-modal" }, data: { open_modal: true, diff --git a/app/config/locales/en.yml b/app/config/locales/en.yml index 8fc62fc7..63a3b382 100644 --- a/app/config/locales/en.yml +++ b/app/config/locales/en.yml @@ -559,6 +559,12 @@ en: tips: Tips unpaid: Unpaid vacation: Vacation + session_timeout: + modal: + description: It looks like you're inactive. For your security, we will sign you out soon unless you choose to extend your session. + end_button: End session + extend_button: Yes, extend my session + heading: Do you need more time? shared: agency_full_name: ma: Massachusetts Department of Transitional Assistance @@ -608,9 +614,3 @@ en: users: omniauth_callbacks: authentication_successful: You are signed in. - session_timeout: - modal: - heading: "Do you need more time?" - description: "It looks like you're inactive. For your security, we will sign you out soon unless you choose to extend your session." - extend_button: "Yes, extend my session" - end_button: "End session" diff --git a/app/config/routes.rb b/app/config/routes.rb index 47abd973..8090fd00 100644 --- a/app/config/routes.rb +++ b/app/config/routes.rb @@ -1,7 +1,6 @@ require "sidekiq/web" Rails.application.routes.draw do - devise_for :users, controllers: { sessions: "users/sessions", @@ -69,8 +68,6 @@ scope :events do post :user_action, to: "user_events#user_action" end - - post '/extend_session', to: 'sessions#extend' end match "/404", to: "pages#error_404", via: :all