From 396631ad7d4c58b61296f999e53bf0c8bdd7c1de Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Mon, 15 Jun 2026 17:28:13 +0530 Subject: [PATCH] feat: enforce concurrent session limit with login picker (CW-7169) (#14621) ## Description Cap active sessions at `MAX_USER_SESSIONS` which defaults to existing value of `25` per user. This ensure existing user login behavior is not affected for self-hosted installations. Browser users at the cap see a session picker (409 response) to choose which session to end. Non-browser clients and partially-tracked users get silent oldest-session eviction. Depends on #14556. ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? Specs cover: under limit, at limit (browser picker, non-browser eviction), partial tracking fallback, revoke single/all sessions during login, session row creation on successful login. --------- Co-authored-by: Sony Mathew --- .../devise_overrides/sessions_controller.rb | 90 +++++++++++ .../components/auth/SessionLimitOverlay.vue | 141 ++++++++++++++++ .../helper/AnalyticsHelper/events.js | 1 + .../dashboard/i18n/locale/en/index.js | 2 + .../i18n/locale/en/sessionLimit.json | 12 ++ app/javascript/v3/api/auth.js | 9 ++ app/javascript/v3/views/login/Index.vue | 72 ++++++++- .../sessions_controller_spec.rb | 152 +++++++++++++++++- 8 files changed, 476 insertions(+), 3 deletions(-) create mode 100644 app/javascript/dashboard/components/auth/SessionLimitOverlay.vue create mode 100644 app/javascript/dashboard/i18n/locale/en/sessionLimit.json diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb index 7dae29d13..587b52c83 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 = ENV.fetch('MAX_USER_SESSIONS', 25).to_i + # Prevent session parameter from being passed # Unpermitted parameter: session wrap_parameters format: [] @@ -14,6 +16,7 @@ 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 @@ -54,6 +57,8 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController end def handle_sso_authentication + return if !@impersonation && enforce_session_limit_for_password_login(@resource) + authenticate_resource_with_sso_token yield @resource if block_given? render_create_success @@ -117,6 +122,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! @@ -129,6 +135,90 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController render json: { error: I18n.t(message_key) }, status: status end + def sessions_limit_reached?(user) + active_token_count(user) >= MAX_SESSIONS + end + + def active_token_count(user) + now = Time.current.to_i + (user.tokens || {}).count { |_, v| v['expiry'].to_i > now } + end + + # Returns true when a response has been rendered (e.g., 409 picker). Non-browser clients + # auto-evict instead of getting stuck on a UI they can't render. + 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) + + # Picker only when every token has a tracked session; partial tracking would + # show a misleading count, so fall through to silent eviction instead. + if browser_request? && user.user_sessions.count >= user.tokens.size + 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) + # Drop pre-rollout untracked tokens first so freshly tracked logins aren't evicted. + return evict_oldest_token(user) if user.user_sessions.count < user.tokens.size + + 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 + + PICKER_SESSION_FIELDS = %i[id browser_name browser_version device_name platform_name platform_version + ip_address city country last_activity_at created_at].freeze + + def handle_sessions_limit_for_login(user) + sessions = user.user_sessions.order(last_activity_at: :desc).map { |s| s.slice(*PICKER_SESSION_FIELDS) } + 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? diff --git a/app/javascript/dashboard/components/auth/SessionLimitOverlay.vue b/app/javascript/dashboard/components/auth/SessionLimitOverlay.vue new file mode 100644 index 000000000..a3eed7184 --- /dev/null +++ b/app/javascript/dashboard/components/auth/SessionLimitOverlay.vue @@ -0,0 +1,141 @@ + + + diff --git a/app/javascript/dashboard/helper/AnalyticsHelper/events.js b/app/javascript/dashboard/helper/AnalyticsHelper/events.js index 751229404..58d2821ef 100644 --- a/app/javascript/dashboard/helper/AnalyticsHelper/events.js +++ b/app/javascript/dashboard/helper/AnalyticsHelper/events.js @@ -155,6 +155,7 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({ }); export const SESSION_EVENTS = Object.freeze({ + LIMIT_HIT: 'Session limit reached at login', REVOKED_FROM_PROFILE: 'Revoked an active session', }); 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/v3/api/auth.js b/app/javascript/v3/api/auth.js index 4c4ffb13e..e7f87824a 100644 --- a/app/javascript/v3/api/auth.js +++ b/app/javascript/v3/api/auth.js @@ -43,6 +43,15 @@ export const login = async ({ mfaToken: error.response.data.mfa_token, }; } + if ( + error.response?.status === 409 && + error.response?.data?.sessions_limit_reached + ) { + return { + sessionsLimitReached: true, + sessions: error.response.data.sessions, + }; + } const loginError = new Error(parseAPIErrorResponse(error)); loginError.errorCode = error.response?.data?.error_code; throw loginError; diff --git a/app/javascript/v3/views/login/Index.vue b/app/javascript/v3/views/login/Index.vue index 2f8cf70ef..54f6996a0 100644 --- a/app/javascript/v3/views/login/Index.vue +++ b/app/javascript/v3/views/login/Index.vue @@ -8,6 +8,8 @@ import { useVuelidate } from '@vuelidate/core'; import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage'; import SessionStorage from 'shared/helpers/sessionStorage'; import { useBranding } from 'shared/composables/useBranding'; +import AnalyticsHelper from 'dashboard/helper/AnalyticsHelper'; +import { SESSION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; // components import SimpleDivider from '../../components/Divider/SimpleDivider.vue'; @@ -17,6 +19,7 @@ import Spinner from 'shared/components/Spinner.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue'; import NextButton from 'dashboard/components-next/button/Button.vue'; import MfaVerification from 'dashboard/components/auth/MfaVerification.vue'; +import SessionLimitOverlay from 'dashboard/components/auth/SessionLimitOverlay.vue'; const ERROR_MESSAGES = { 'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND', @@ -36,6 +39,7 @@ export default { NextButton, SimpleDivider, MfaVerification, + SessionLimitOverlay, Icon, }, props: { @@ -68,6 +72,8 @@ export default { error: '', mfaRequired: false, mfaToken: null, + sessionsLimitReached: false, + limitedSessions: [], }; }, validations() { @@ -182,6 +188,15 @@ export default { return; } + // Check if sessions limit reached + if (result?.sessionsLimitReached) { + this.loginApi.showLoading = false; + this.sessionsLimitReached = true; + this.limitedSessions = result.sessions; + AnalyticsHelper.track(SESSION_EVENTS.LIMIT_HIT); + return; + } + this.handleImpersonation(); this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE')); }) @@ -224,6 +239,51 @@ export default { this.mfaToken = null; this.credentials.password = ''; }, + retryLoginWithParams(extraParams) { + const credentials = { + email: this.email + ? decodeURIComponent(this.email) + : this.credentials.email, + password: this.credentials.password, + sso_auth_token: this.ssoAuthToken, + ssoAccountId: this.ssoAccountId, + ssoConversationId: this.ssoConversationId, + ...extraParams, + }; + + this.sessionsLimitReached = false; + this.limitedSessions = []; + this.loginApi.showLoading = true; + login(credentials) + .then(result => { + if (result?.sessionsLimitReached) { + this.loginApi.showLoading = false; + this.sessionsLimitReached = true; + this.limitedSessions = result.sessions; + AnalyticsHelper.track(SESSION_EVENTS.LIMIT_HIT); + 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 +315,18 @@ export default {

+ +
+ +
+ -
+
{ 'token' => 'x', 'expiry' => (Time.current + expiry_offset_days.days).to_i } + ) + user.save! + user.user_sessions.create!(client_id: client_id, last_activity_at: Time.current) if with_session + end + + def login_params + { email: user.email, password: 'Test@123456' } + end + + context 'when under the limit' do + it 'allows login without intervention' do + request.env['HTTP_USER_AGENT'] = browser_ua + 3.times { |i| seed_token("c#{i}", expiry_offset_days: 30) } + + post :create, params: login_params + + expect(response).to have_http_status(:success) + end + + it 'does not count expired tokens toward the cap' do + request.env['HTTP_USER_AGENT'] = browser_ua + # 3 expired + 2 active = 5 raw entries, but only 2 active + 3.times { |i| seed_token("expired#{i}", expiry_offset_days: -1, with_session: false) } + 2.times { |i| seed_token("active#{i}", expiry_offset_days: 30) } + + post :create, params: login_params + + expect(response).to have_http_status(:success) + end + end + + context 'when at the limit from a browser with full tracking' do + before do + request.env['HTTP_USER_AGENT'] = browser_ua + 5.times { |i| seed_token("c#{i}", expiry_offset_days: 30) } + end + + it 'returns 409 with the session list (picker)' do + post :create, params: login_params + + expect(response).to have_http_status(:conflict) + body = response.parsed_body + expect(body['sessions_limit_reached']).to be true + expect(body['sessions'].size).to eq(5) + end + + it 'does not create a new session row' do + expect { post :create, params: login_params }.not_to change(user.user_sessions, :count) + end + end + + context 'when at the limit from a non-browser client' do + before do + request.env['HTTP_USER_AGENT'] = mobile_ua + 5.times { |i| seed_token("c#{i}", expiry_offset_days: 30 + i, with_session: false) } + end + + it 'silently evicts the oldest token and lets login proceed' do + post :create, params: login_params + + expect(response).to have_http_status(:success) + expect(user.reload.tokens.keys).not_to include('c0') + end + end + + context 'when at the limit but tracking is partial (legacy tokens present)' do + before do + request.env['HTTP_USER_AGENT'] = browser_ua + # one tracked, four legacy (no user_session rows) + seed_token('tracked', expiry_offset_days: 60, with_session: true) + 4.times { |i| seed_token("legacy#{i}", expiry_offset_days: 10 + i, with_session: false) } + end + + it 'silent-evicts instead of showing a partial picker' do + post :create, params: login_params + + expect(response).to have_http_status(:success) + end + + it 'drops an untracked token first, keeping the tracked session alive' do + post :create, params: login_params + + tokens = user.reload.tokens.keys + expect(tokens).to include('tracked') + # legacy0 expires soonest -> evict_oldest_token picks it + expect(tokens).not_to include('legacy0') + end + end + + context 'when at the limit with full tracking (no legacy gap)' do + before do + request.env['HTTP_USER_AGENT'] = mobile_ua + # Five tracked sessions, varying activity timestamps + 5.times do |i| + seed_token("tracked#{i}", expiry_offset_days: 30) + user.user_sessions.find_by(client_id: "tracked#{i}").update!(last_activity_at: (5 - i).days.ago) + end + end + + it 'evicts the oldest tracked session by last_activity_at' do + post :create, params: login_params + + expect(response).to have_http_status(:success) + # tracked0 had the oldest last_activity_at (5 days ago) + expect(user.reload.tokens.keys).not_to include('tracked0') + expect(user.user_sessions.exists?(client_id: 'tracked0')).to be false + end + end + + context 'with revoke_session_id during login' do + before do + request.env['HTTP_USER_AGENT'] = browser_ua + 5.times { |i| seed_token("c#{i}", expiry_offset_days: 30) } + end + + it 'revokes the chosen session and proceeds with login' do + target = user.user_sessions.find_by(client_id: 'c2') + + post :create, params: login_params.merge(revoke_session_id: target.id) + + expect(response).to have_http_status(:success) + expect(user.reload.tokens.keys).not_to include('c2') + expect(user.user_sessions.exists?(id: target.id)).to be false + end + end + + context 'with revoke_all_sessions during login' do + before do + request.env['HTTP_USER_AGENT'] = browser_ua + 5.times { |i| seed_token("c#{i}", expiry_offset_days: 30) } + end + + it 'wipes all sessions and tokens, then proceeds with login' do + post :create, params: login_params.merge(revoke_all_sessions: true) + + expect(response).to have_http_status(:success) + expect(user.reload.tokens.keys).not_to include('c0', 'c1', 'c2', 'c3', 'c4') + # the new login adds one fresh token + expect(user.tokens.keys.size).to eq(1) + end + end context 'with a successful login' do before { request.env['HTTP_USER_AGENT'] = browser_ua } it 'creates a UserSession row for the new client_id' do - expect { post :create, params: { email: user.email, password: 'Test@123456' } }.to change(user.user_sessions, :count).by(1) + expect { post :create, params: login_params }.to change(user.user_sessions, :count).by(1) session = user.user_sessions.last expect(session.browser_name).to eq('Safari')