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 <sony@chatwoot.com>
This commit is contained in:
Vishnu Narayanan
2026-06-15 17:28:13 +05:30
committed by GitHub
co-authored by Sony Mathew
parent ba0ba46c9c
commit 396631ad7d
8 changed files with 476 additions and 3 deletions
@@ -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?
@@ -0,0 +1,141 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { format, parseISO } from 'date-fns';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
sessions: {
type: Array,
required: true,
},
});
const emit = defineEmits(['revoke', 'revokeAll', 'cancel']);
const { t } = useI18n();
const revokingId = ref(null);
const revokingAll = ref(false);
const sortedSessions = computed(() =>
[...props.sessions].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at)
)
);
const formatDate = dateStr => {
if (!dateStr) return '';
return format(parseISO(dateStr), 'MMMM d, yyyy');
};
const formatTime = dateStr => {
if (!dateStr) return '';
return format(parseISO(dateStr), 'hh:mma');
};
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
const sessionLabel = session => {
const parts = [];
if (!isUnknown(session.browser_name)) parts.push(session.browser_name);
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
return parts.join(' on ') || t('SESSION_LIMIT.UNKNOWN_DEVICE');
};
watch(
() => props.sessions,
() => {
revokingId.value = null;
revokingAll.value = false;
}
);
const handleRevoke = session => {
revokingId.value = session.id;
emit('revoke', session.id);
};
const handleRevokeAll = () => {
revokingAll.value = true;
emit('revokeAll');
};
</script>
<template>
<div class="w-full max-w-lg mx-auto">
<div
class="bg-white shadow dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
>
<!-- Header -->
<div class="flex items-start justify-between gap-4 mb-6">
<div>
<h2 class="text-2xl font-semibold text-n-slate-12">
{{ $t('SESSION_LIMIT.TITLE') }}
</h2>
<p class="text-sm text-n-slate-11 mt-2">
{{ $t('SESSION_LIMIT.DESCRIPTION') }}
</p>
</div>
<NextButton
type="button"
faded
sm
class="flex-shrink-0 whitespace-nowrap"
:label="$t('SESSION_LIMIT.END_ALL')"
:is-loading="revokingAll"
:disabled="revokingId !== null"
@click="handleRevokeAll"
/>
</div>
<!-- Session List -->
<div class="flex flex-col gap-3 max-h-80 overflow-y-auto">
<div
v-for="session in sortedSessions"
:key="session.id"
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background px-4 py-3"
>
<div class="flex items-center gap-3 min-w-0">
<Icon
icon="i-lucide-monitor"
class="size-4 text-n-slate-10 flex-shrink-0"
/>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-sm font-medium text-n-slate-12">
{{ sessionLabel(session) }}
</span>
<span class="text-xs text-n-slate-10">
{{
`${$t('SESSION_LIMIT.STARTED')} ${formatDate(session.created_at)}, ${formatTime(session.created_at)}`
}}
</span>
</div>
</div>
<button
type="button"
class="text-sm font-medium text-n-slate-11 hover:text-n-slate-12 flex-shrink-0 disabled:opacity-50"
:disabled="revokingId !== null || revokingAll"
@click="handleRevoke(session)"
>
{{ $t('SESSION_LIMIT.END') }}
</button>
</div>
</div>
<!-- Cancel -->
<div class="text-center pt-4">
<NextButton
sm
slate
link
type="button"
class="w-full hover:!no-underline"
:label="$t('SESSION_LIMIT.CANCEL')"
@click="() => emit('cancel')"
/>
</div>
</div>
</div>
</template>
@@ -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',
});
@@ -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,
};
@@ -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"
}
}
+9
View File
@@ -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;
+71 -1
View File
@@ -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 = '';
},
},
};
</script>
@@ -255,8 +315,18 @@ export default {
</p>
</section>
<!-- Session Limit Section -->
<section v-if="sessionsLimitReached" class="mt-11">
<SessionLimitOverlay
:sessions="limitedSessions"
@revoke="handleSessionRevoke"
@revoke-all="handleSessionRevokeAll"
@cancel="handleSessionLimitCancel"
/>
</section>
<!-- MFA Verification Section -->
<section v-if="mfaRequired" class="mt-11">
<section v-else-if="mfaRequired" class="mt-11">
<MfaVerification
:mfa-token="mfaToken"
@verified="handleMfaVerified"
@@ -164,15 +164,163 @@ RSpec.describe DeviseOverrides::SessionsController, type: :controller do
end
end
describe 'session tracking' do
describe 'session limit enforcement' do
before { stub_const('DeviseOverrides::SessionsController::MAX_SESSIONS', 5) }
let(:user) { create(:user, password: 'Test@123456') }
let(:browser_ua) { 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15' }
let(:mobile_ua) { 'okhttp/4.9.3' }
def seed_token(client_id, expiry_offset_days: 30, with_session: true)
user.tokens = user.tokens.merge(
client_id => { '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')