diff --git a/app/controllers/api/v1/profile/sessions_controller.rb b/app/controllers/api/v1/profile/sessions_controller.rb new file mode 100644 index 000000000..a707a297c --- /dev/null +++ b/app/controllers/api/v1/profile/sessions_controller.rb @@ -0,0 +1,31 @@ +class Api::V1::Profile::SessionsController < Api::BaseController + before_action :set_session, only: [:destroy] + + def index + @sessions = current_user.user_sessions.order(last_activity_at: :desc) + @current_client_id = request.headers['client'] + end + + def destroy + if @session.current?(request.headers['client']) + render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity + return + end + + revoke_token!(@session.client_id) + @session.destroy! + head :ok + end + + private + + def set_session + @session = current_user.user_sessions.find(params[:id]) + end + + def revoke_token!(client_id) + tokens = current_user.tokens + tokens.delete(client_id) + current_user.update!(tokens: tokens) + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2f389049d..9dea4b4da 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base include RequestExceptionHandler include Pundit::Authorization include SwitchLocale + include TrackSessionActivity skip_before_action :verify_authenticity_token diff --git a/app/controllers/concerns/track_session_activity.rb b/app/controllers/concerns/track_session_activity.rb new file mode 100644 index 000000000..f6a512922 --- /dev/null +++ b/app/controllers/concerns/track_session_activity.rb @@ -0,0 +1,22 @@ +module TrackSessionActivity + extend ActiveSupport::Concern + + included do + after_action :update_session_activity + end + + private + + def update_session_activity + return unless current_user + return if request.headers['client'].blank? + + UserSessionTrackingService.new( + user: current_user, + request: request, + client_id: request.headers['client'] + ).update_activity! + rescue StandardError => e + Rails.logger.warn "Session activity update failed: #{e.message}" + end +end diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb index bd7bb9b44..bb93c060c 100644 --- a/app/controllers/devise_overrides/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -1,4 +1,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController + MAX_SESSIONS = 5 + # Prevent session parameter from being passed # Unpermitted parameter: session wrap_parameters format: [] @@ -14,12 +16,14 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController user = find_user_for_authentication return handle_mfa_required(user) if user&.mfa_enabled? + return if user && enforce_session_limit_for_password_login(user) # Only proceed with standard authentication if no MFA is required super end def render_create_success + track_user_session render partial: 'devise/auth', formats: [:json], locals: { resource: @resource } end @@ -53,6 +57,8 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController end def handle_sso_authentication + return if enforce_session_limit_for_password_login(@resource) + authenticate_resource_with_sso_token yield @resource if block_given? render_create_success @@ -103,6 +109,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController end def sign_in_mfa_user(user) + evict_oldest_session(user) if sessions_limit_reached?(user) @resource = user @token = @resource.create_token @resource.save! @@ -114,6 +121,109 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController def render_mfa_error(message_key, status = :bad_request) render json: { error: I18n.t(message_key) }, status: status end + + def sessions_limit_reached?(user) + (user.tokens || {}).keys.size >= MAX_SESSIONS + end + + # Returns true when the response has been rendered (e.g., 409 picker shown). Browsers see + # the picker; non-browser clients (mobile, API) auto-evict so they don't get stuck on a UI + # they can't render. If the user is revoking, perform the revoke and let login proceed. + def enforce_session_limit_for_password_login(user) + if revoking_sessions? + revoke_sessions_for_login(user) + return false + end + + return false unless sessions_limit_reached?(user) + + if browser_request? + handle_sessions_limit_for_login(user) + true + else + evict_oldest_session(user) + false + end + end + + def browser_request? + request.user_agent.to_s.include?('Mozilla') + end + + def revoking_sessions? + params[:revoke_session_id].present? || params[:revoke_all_sessions].present? + end + + def revoke_sessions_for_login(user) + if params[:revoke_all_sessions].present? + user.tokens = {} + user.save! + user.user_sessions.destroy_all + elsif params[:revoke_session_id].present? + session = user.user_sessions.find_by(id: params[:revoke_session_id]) + return unless session + + user.tokens.delete(session.client_id) + user.save! + session.destroy! + end + end + + def evict_oldest_session(user) + oldest_session = user.user_sessions.order(Arel.sql('COALESCE(last_activity_at, created_at) ASC')).first + return evict_oldest_token(user) unless oldest_session + + user.tokens.delete(oldest_session.client_id) + user.save! + oldest_session.destroy! + end + + # Fallback if a token exists without a UserSession row (e.g., legacy data before tracking shipped). + def evict_oldest_token(user) + return if user.tokens.blank? + + oldest_client_id = user.tokens.min_by { |_, v| v['expiry'].to_i }&.first + return unless oldest_client_id + + user.tokens.delete(oldest_client_id) + user.save! + end + + def handle_sessions_limit_for_login(user) + sessions = user.user_sessions.order(last_activity_at: :desc).map do |session| + { + id: session.id, + browser_name: session.browser_name, + browser_version: session.browser_version, + device_name: session.device_name, + platform_name: session.platform_name, + platform_version: session.platform_version, + ip_address: session.ip_address, + city: session.city, + country: session.country, + last_activity_at: session.last_activity_at, + created_at: session.created_at + } + end + + render json: { + sessions_limit_reached: true, + sessions: sessions + }, status: :conflict + end + + def track_user_session + client_id = @token&.try(:client) || response.headers['client'] + return unless client_id.present? && @resource.present? + + UserSessionTrackingService.new( + user: @resource, + request: request, + client_id: client_id + ).create_or_update! + rescue StandardError => e + Rails.logger.warn "Session tracking failed: #{e.message}" + end end DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController') diff --git a/app/javascript/dashboard/api/auth.js b/app/javascript/dashboard/api/auth.js index a1b15ee79..b9dc59964 100644 --- a/app/javascript/dashboard/api/auth.js +++ b/app/javascript/dashboard/api/auth.js @@ -106,4 +106,10 @@ export default { const urlData = endPoints('resetAccessToken'); return axios.post(urlData.url); }, + getSessions() { + return axios.get('/api/v1/profile/sessions'); + }, + revokeSession(id) { + return axios.delete(`/api/v1/profile/sessions/${id}`); + }, }; diff --git a/app/javascript/dashboard/components/auth/SessionLimitOverlay.vue b/app/javascript/dashboard/components/auth/SessionLimitOverlay.vue new file mode 100644 index 000000000..98fdc3605 --- /dev/null +++ b/app/javascript/dashboard/components/auth/SessionLimitOverlay.vue @@ -0,0 +1,133 @@ + + + diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js index 31486a247..12db16ba7 100644 --- a/app/javascript/dashboard/i18n/locale/en/index.js +++ b/app/javascript/dashboard/i18n/locale/en/index.js @@ -40,6 +40,7 @@ import whatsappTemplates from './whatsappTemplates.json'; import contentTemplates from './contentTemplates.json'; import mfa from './mfa.json'; import onboarding from './onboarding.json'; +import sessionLimit from './sessionLimit.json'; import yearInReview from './yearInReview.json'; export default { @@ -85,5 +86,6 @@ export default { ...contentTemplates, ...mfa, ...onboarding, + ...sessionLimit, ...yearInReview, }; diff --git a/app/javascript/dashboard/i18n/locale/en/sessionLimit.json b/app/javascript/dashboard/i18n/locale/en/sessionLimit.json new file mode 100644 index 000000000..926745c23 --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/en/sessionLimit.json @@ -0,0 +1,12 @@ +{ + "SESSION_LIMIT": { + "TITLE": "Active session limit reached", + "DESCRIPTION": "You have reached your limit of active sessions. Please end a session before logging in.", + "END": "End", + "END_ALL": "End all sessions", + "LOG_IN": "Log in", + "CANCEL": "Back to login", + "UNKNOWN_DEVICE": "Unknown device", + "STARTED": "Started" + } +} diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index bfbd920a7..08a4f1133 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -86,6 +86,16 @@ "NOTE": "Manage additional security features for your account.", "MFA_BUTTON": "Manage Two-Factor Authentication" }, + "SESSIONS_SECTION": { + "TITLE": "Active Sessions", + "NOTE": "These are the devices currently logged in to your account.", + "CURRENT": "Current session", + "REVOKE": "Revoke", + "REVOKE_SUCCESS": "Session revoked successfully", + "REVOKE_ERROR": "Unable to revoke session. Please try again.", + "FETCH_ERROR": "Unable to fetch sessions. Please try again.", + "LAST_ACTIVE": "Last active" + }, "ACCESS_TOKEN": { "TITLE": "Access Token", "NOTE": "This token can be used if you are building an API based integration", diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/ActiveSessions.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/ActiveSessions.vue new file mode 100644 index 000000000..eab050b39 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/ActiveSessions.vue @@ -0,0 +1,131 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue index 75eb8a2f8..04de7bda2 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue @@ -20,6 +20,7 @@ import SectionLayout from '../account/components/SectionLayout.vue'; import BaseSettingsHeader from '../components/BaseSettingsHeader.vue'; import AccessToken from './AccessToken.vue'; import MfaSettingsCard from './MfaSettingsCard.vue'; +import ActiveSessions from './ActiveSessions.vue'; import Policy from 'dashboard/components/policy.vue'; import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue'; import { @@ -42,6 +43,7 @@ export default { AudioNotifications, AccessToken, MfaSettingsCard, + ActiveSessions, BaseSettingsHeader, }, setup() { @@ -307,6 +309,13 @@ export default { > + + + { + if (result?.sessionsLimitReached) { + this.loginApi.showLoading = false; + this.sessionsLimitReached = true; + this.limitedSessions = result.sessions; + return; + } + this.handleImpersonation(); + this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE')); + }) + .catch(response => { + this.loginApi.hasErrored = true; + this.showAlertMessage( + response?.message || this.$t('LOGIN.API.UNAUTH') + ); + }); + }, + handleSessionRevoke(sessionId) { + this.retryLoginWithParams({ revoke_session_id: sessionId }); + }, + handleSessionRevokeAll() { + this.retryLoginWithParams({ revoke_all_sessions: true }); + }, + handleSessionLimitCancel() { + this.sessionsLimitReached = false; + this.limitedSessions = []; + this.credentials.password = ''; + }, }, }; @@ -255,8 +311,18 @@ export default {

+ +
+ +
+ -
+
{ order('lower(name) ASC') } @@ -214,6 +216,11 @@ class User < ApplicationRecord private + def sync_user_sessions + active_client_ids = (tokens || {}).keys + user_sessions.where.not(client_id: active_client_ids).destroy_all + end + def remove_macros macros.personal.destroy_all end diff --git a/app/models/user_session.rb b/app/models/user_session.rb new file mode 100644 index 000000000..0f2503382 --- /dev/null +++ b/app/models/user_session.rb @@ -0,0 +1,46 @@ +# == Schema Information +# +# Table name: user_sessions +# +# id :bigint not null, primary key +# browser_name :string +# browser_version :string +# city :string +# country :string +# country_code :string +# device_name :string +# ip_address :string +# last_activity_at :datetime +# platform_name :string +# platform_version :string +# user_agent :string +# created_at :datetime not null +# updated_at :datetime not null +# client_id :string not null +# user_id :bigint not null +# +# Indexes +# +# index_user_sessions_on_user_id (user_id) +# index_user_sessions_on_user_id_and_client_id (user_id,client_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (user_id => users.id) +# + +class UserSession < ApplicationRecord + ACTIVITY_THROTTLE = 5.minutes + + belongs_to :user + + validates :client_id, presence: true, uniqueness: { scope: :user_id } + + def current?(active_client_id) + client_id == active_client_id + end + + def should_update_activity? + last_activity_at.nil? || last_activity_at < ACTIVITY_THROTTLE.ago + end +end diff --git a/app/services/user_session_tracking_service.rb b/app/services/user_session_tracking_service.rb new file mode 100644 index 000000000..537c046e2 --- /dev/null +++ b/app/services/user_session_tracking_service.rb @@ -0,0 +1,42 @@ +class UserSessionTrackingService + def initialize(user:, request:, client_id:) + @user = user + @request = request + @client_id = client_id + end + + def create_or_update! + session = @user.user_sessions.find_or_initialize_by(client_id: @client_id) + session.assign_attributes(session_attributes) + session.last_activity_at = Time.current + session.save! + session + end + + def update_activity! + session = @user.user_sessions.find_by(client_id: @client_id) + return unless session&.should_update_activity? + + session.update_columns(last_activity_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + + private + + def session_attributes + browser = Browser.new(@request.user_agent) + location = IpLookupService.new.perform(@request.remote_ip) + + { + ip_address: @request.remote_ip, + user_agent: @request.user_agent, + browser_name: browser.name, + browser_version: browser.full_version, + device_name: browser.device.name, + platform_name: browser.platform.name, + platform_version: browser.platform.version, + city: location&.city, + country: location&.country, + country_code: location&.country_code + } + end +end diff --git a/app/views/api/v1/profile/sessions/index.json.jbuilder b/app/views/api/v1/profile/sessions/index.json.jbuilder new file mode 100644 index 000000000..b009271e0 --- /dev/null +++ b/app/views/api/v1/profile/sessions/index.json.jbuilder @@ -0,0 +1,15 @@ +json.array! @sessions do |session| + json.id session.id + json.browser_name session.browser_name + json.browser_version session.browser_version + json.device_name session.device_name + json.platform_name session.platform_name + json.platform_version session.platform_version + json.ip_address session.ip_address + json.city session.city + json.country session.country + json.country_code session.country_code + json.last_activity_at session.last_activity_at + json.created_at session.created_at + json.current session.current?(@current_client_id) +end diff --git a/config/initializers/devise_token_auth.rb b/config/initializers/devise_token_auth.rb index e8500019b..fd3b28fdc 100644 --- a/config/initializers/devise_token_auth.rb +++ b/config/initializers/devise_token_auth.rb @@ -15,7 +15,7 @@ DeviseTokenAuth.setup do |config| # Sets the max number of concurrent devices per user, which is 10 by default. # After this limit is reached, the oldest tokens will be removed. - config.max_number_of_devices = 25 + config.max_number_of_devices = 10 # Sometimes it's necessary to make several requests to the API at the same # time. In this case, each request in the batch will need to share the same diff --git a/config/locales/en.yml b/config/locales/en.yml index b11a4fdae..465a130f9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -47,6 +47,10 @@ en: saml_not_available: SAML authentication is not available in this installation. inbox_deletetion_response: Your inbox deletion request will be processed in some time. + profile_settings: + sessions: + cannot_revoke_current: You cannot revoke the current session. + errors: account: reporting_timezone: diff --git a/config/routes.rb b/config/routes.rb index aeb7fe525..a8dbb3384 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -433,6 +433,7 @@ Rails.application.routes.draw do post :verify post :backup_codes end + resources :sessions, only: [:index, :destroy] end end diff --git a/db/migrate/20260523000000_create_user_sessions.rb b/db/migrate/20260523000000_create_user_sessions.rb new file mode 100644 index 000000000..96ad2821e --- /dev/null +++ b/db/migrate/20260523000000_create_user_sessions.rb @@ -0,0 +1,23 @@ +class CreateUserSessions < ActiveRecord::Migration[7.0] + def change + create_table :user_sessions do |t| + t.references :user, null: false, foreign_key: true + t.string :client_id, null: false + t.string :ip_address + t.string :user_agent + t.string :browser_name + t.string :browser_version + t.string :device_name + t.string :platform_name + t.string :platform_version + t.string :city + t.string :country + t.string :country_code + t.datetime :last_activity_at + + t.timestamps + end + + add_index :user_sessions, [:user_id, :client_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index f2c479571..07b8d7bb7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1252,6 +1252,26 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do t.index ["name", "account_id"], name: "index_teams_on_name_and_account_id", unique: true end + create_table "user_sessions", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "client_id", null: false + t.string "ip_address" + t.string "user_agent" + t.string "browser_name" + t.string "browser_version" + t.string "device_name" + t.string "platform_name" + t.string "platform_version" + t.string "city" + t.string "country" + t.string "country_code" + t.datetime "last_activity_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id", "client_id"], name: "index_user_sessions_on_user_id_and_client_id", unique: true + t.index ["user_id"], name: "index_user_sessions_on_user_id" + end + create_table "users", id: :serial, force: :cascade do |t| t.string "provider", default: "email", null: false t.string "uid", default: "", null: false @@ -1324,6 +1344,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "inboxes", "portals" + add_foreign_key "user_sessions", "users" create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1). on("accounts"). after(:insert).