Compare commits

...
Author SHA1 Message Date
Vishnu NarayananandVishnu Narayanan 2228a6c0ed chore: fix rubocop 2026-06-02 13:11:58 +05:30
Vishnu NarayananandVishnu Narayanan a7ea02a029 chore: track session limit events 2026-06-02 13:11:58 +05:30
Vishnu NarayananandVishnu Narayanan 2534875cf3 chore: add specs for session limit feature 2026-06-02 13:11:58 +05:30
Vishnu NarayananandVishnu Narayanan fcf660b7d9 fix: evict untracked legacy tokens before tracked sessions
During the rollout window, tracked sessions are newer than any
pre-existing legacy tokens. The previous logic walked user_sessions
first and would kill the only tracked session while leaving older
legacy tokens alive. Drop untracked tokens first when there is a
gap between user_sessions count and user.tokens size.
2026-06-02 13:11:58 +05:30
Vishnu NarayananandVishnu Narayanan 1af8334ea8 fix: silent-evict legacy tokens when picker would show partial sessions 2026-06-02 13:11:58 +05:30
Vishnu NarayananandVishnu Narayanan 13ad505bbb feat: limit concurrent sessions per user with active session management (CW-7169) 2026-06-02 13:11:58 +05:30
28 changed files with 1144 additions and 2 deletions
@@ -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
@@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base
include RequestExceptionHandler
include Pundit::Authorization
include SwitchLocale
include TrackSessionActivity
skip_before_action :verify_authenticity_token
@@ -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
@@ -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,115 @@ 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)
# 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)
# Untracked tokens are pre-rollout leftovers and almost always older than any
# tracked session; drop those 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
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')
+6
View File
@@ -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}`);
},
};
@@ -0,0 +1,133 @@
<script setup>
import { ref, computed } 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');
};
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>
@@ -154,6 +154,11 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
SHARE_CLICKED: 'Year in Review: Share clicked',
});
export const SESSION_EVENTS = Object.freeze({
LIMIT_HIT: 'Session limit reached at login',
REVOKED_FROM_PROFILE: 'Revoked an active session',
});
export const ONBOARDING_EVENTS = Object.freeze({
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
@@ -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"
}
}
@@ -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",
@@ -0,0 +1,134 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatDistanceToNow, parseISO } from 'date-fns';
import { useAlert } from 'dashboard/composables';
import authAPI from 'dashboard/api/auth';
import AnalyticsHelper from 'dashboard/helper/AnalyticsHelper';
import { SESSION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const sessions = ref([]);
const loading = ref(false);
const relativeTime = dateStr => {
if (!dateStr) return '';
return formatDistanceToNow(parseISO(dateStr), { addSuffix: true });
};
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
const deviceIcon = session => {
const name = (session.device_name || '').toLowerCase();
if (
name.includes('iphone') ||
name.includes('android') ||
name.includes('mobile')
) {
return 'i-lucide-smartphone';
}
if (name.includes('ipad') || name.includes('tablet')) {
return 'i-lucide-tablet';
}
return 'i-lucide-monitor';
};
const sessionLabel = session => {
const parts = [];
if (!isUnknown(session.browser_name)) {
parts.push(
session.browser_version
? `${session.browser_name} ${session.browser_version}`
: session.browser_name
);
}
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
return parts.join(' on ') || t('SESSION_LIMIT.UNKNOWN_DEVICE');
};
const locationLabel = session => {
const parts = [];
if (session.city) parts.push(session.city);
if (session.country) parts.push(session.country);
return parts.join(', ');
};
const fetchSessions = async () => {
loading.value = true;
try {
const { data } = await authAPI.getSessions();
sessions.value = data;
} catch {
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.FETCH_ERROR'));
} finally {
loading.value = false;
}
};
const revokeSession = async session => {
try {
await authAPI.revokeSession(session.id);
sessions.value = sessions.value.filter(s => s.id !== session.id);
AnalyticsHelper.track(SESSION_EVENTS.REVOKED_FROM_PROFILE);
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_SUCCESS'));
} catch {
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_ERROR'));
}
};
onMounted(fetchSessions);
</script>
<template>
<div class="flex flex-col gap-3">
<div
v-for="session in sessions"
:key="session.id"
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background p-4"
>
<div class="flex items-start gap-3">
<Icon
:icon="deviceIcon(session)"
class="size-5 mt-0.5 text-n-slate-10 flex-shrink-0"
/>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-heading-3 text-n-slate-12">
{{ sessionLabel(session) }}
</span>
<span
v-if="session.current"
class="rounded-full bg-n-teal-3 px-2 py-0.5 text-caption text-n-teal-11"
>
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.CURRENT') }}
</span>
</div>
<span
v-if="locationLabel(session)"
class="text-body-b3 text-n-slate-11"
>
{{ locationLabel(session) }}
</span>
<span
v-if="session.last_activity_at"
class="text-body-b3 text-n-slate-10"
>
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.LAST_ACTIVE') }}
{{ relativeTime(session.last_activity_at) }}
</span>
</div>
</div>
<Button
v-if="!session.current"
type="button"
faded
xs
:label="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE')"
color-scheme="alert"
@click="revokeSession(session)"
/>
</div>
</div>
</template>
@@ -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 {
>
<MfaSettingsCard />
</SectionLayout>
<SectionLayout
with-border
:title="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.NOTE')"
>
<ActiveSessions />
</SectionLayout>
<Policy :permissions="audioNotificationPermissions">
<SectionLayout
with-border
+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"
+7
View File
@@ -101,6 +101,7 @@ class User < ApplicationRecord
has_many :messages, as: :sender, dependent: :nullify
has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify
has_many :user_sessions, dependent: :destroy
has_many :custom_filters, dependent: :destroy_async
has_many :dashboard_apps, dependent: :nullify
has_many :mentions, dependent: :destroy_async
@@ -118,6 +119,7 @@ class User < ApplicationRecord
before_validation :set_password_and_uid, on: :create
after_destroy :remove_macros
after_save :sync_user_sessions, if: :saved_change_to_tokens?
scope :order_by_full_name, -> { 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
+46
View File
@@ -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
@@ -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
@@ -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
+1 -1
View File
@@ -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
+4
View File
@@ -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:
+1
View File
@@ -433,6 +433,7 @@ Rails.application.routes.draw do
post :verify
post :backup_codes
end
resources :sessions, only: [:index, :destroy]
end
end
@@ -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
+21
View File
@@ -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).
@@ -163,4 +163,156 @@ RSpec.describe DeviseOverrides::SessionsController, type: :controller do
expect(response).to redirect_to('/frontend/app/login?error=access-denied')
end
end
describe 'session limit enforcement' do
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
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: login_params }.to change(user.user_sessions, :count).by(1)
session = user.user_sessions.last
expect(session.browser_name).to eq('Safari')
expect(session.platform_name).to eq('macOS')
end
end
end
end
+61
View File
@@ -0,0 +1,61 @@
require 'rails_helper'
RSpec.describe UserSession do
let(:user) { create(:user) }
describe 'associations' do
it { is_expected.to belong_to(:user) }
end
describe 'validations' do
subject { described_class.new(user: user, client_id: 'abc') }
it { is_expected.to validate_presence_of(:client_id) }
it 'validates uniqueness of client_id scoped to user_id' do
described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current)
duplicate = described_class.new(user: user, client_id: 'abc')
expect(duplicate).not_to be_valid
expect(duplicate.errors[:client_id]).to be_present
end
it 'allows the same client_id for different users' do
other = create(:user)
described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current)
expect(described_class.new(user: other, client_id: 'abc', last_activity_at: Time.current)).to be_valid
end
end
describe '#current?' do
let(:session) { described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current) }
it 'returns true when client_id matches' do
expect(session.current?('abc')).to be true
end
it 'returns false when client_id differs' do
expect(session.current?('xyz')).to be false
end
end
describe '#should_update_activity?' do
let(:session) { described_class.new(user: user, client_id: 'abc') }
it 'returns true when last_activity_at is nil' do
session.last_activity_at = nil
expect(session.should_update_activity?).to be true
end
it 'returns true when last_activity_at is older than the throttle window' do
session.last_activity_at = 10.minutes.ago
expect(session.should_update_activity?).to be true
end
it 'returns false when last_activity_at is within the throttle window' do
session.last_activity_at = 1.minute.ago
expect(session.should_update_activity?).to be false
end
end
end
+33
View File
@@ -254,4 +254,37 @@ RSpec.describe User do
end
end
end
describe 'sync_user_sessions callback' do
let(:user_with_tokens) do
u = create(:user)
u.tokens = {
'client-a' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i },
'client-b' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i }
}
u.save!
u.user_sessions.create!(client_id: 'client-a', last_activity_at: Time.current)
u.user_sessions.create!(client_id: 'client-b', last_activity_at: Time.current)
u
end
it 'destroys user_sessions whose client_id is no longer in tokens' do
user_with_tokens.tokens = user_with_tokens.tokens.except('client-a')
expect { user_with_tokens.save! }.to change(user_with_tokens.user_sessions, :count).by(-1)
expect(user_with_tokens.user_sessions.pluck(:client_id)).to eq(['client-b'])
end
it 'leaves user_sessions alone when tokens did not change' do
user_with_tokens.update!(name: 'New Name')
expect(user_with_tokens.user_sessions.count).to eq(2)
end
it 'destroys all user_sessions when tokens is cleared' do
user_with_tokens.tokens = {}
expect { user_with_tokens.save! }.to change(user_with_tokens.user_sessions, :count).by(-2)
end
end
end
@@ -0,0 +1,87 @@
require 'rails_helper'
RSpec.describe 'Profile Sessions API', type: :request do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:auth_headers) { user.create_new_auth_token }
let(:current_client_id) { auth_headers['client'] }
describe 'GET /api/v1/profile/sessions' do
it 'returns 401 without auth' do
get '/api/v1/profile/sessions', as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'returns the current user sessions ordered by last_activity_at desc' do
older = user.user_sessions.create!(client_id: current_client_id, browser_name: 'Chrome', last_activity_at: 2.days.ago)
newer = user.user_sessions.create!(client_id: 'other-client', browser_name: 'Firefox', last_activity_at: 1.hour.ago)
get '/api/v1/profile/sessions', headers: auth_headers, as: :json
expect(response).to have_http_status(:success)
sessions = response.parsed_body
expect(sessions.map { |s| s['id'] }).to eq([newer.id, older.id])
expect(sessions.find { |s| s['id'] == older.id }['current']).to be true
expect(sessions.find { |s| s['id'] == newer.id }['current']).to be false
end
it 'returns an empty array when no sessions exist' do
get '/api/v1/profile/sessions', headers: auth_headers, as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body).to eq([])
end
end
describe 'DELETE /api/v1/profile/sessions/:id' do
let!(:other_session) { user.user_sessions.create!(client_id: 'other-client', last_activity_at: 1.hour.ago) }
before do
# Seed tokens hash so revoke can clean it up
user.tokens = user.tokens.merge('other-client' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i })
user.save!
end
it 'destroys the session and removes its token entry' do
expect do
delete "/api/v1/profile/sessions/#{other_session.id}", headers: auth_headers, as: :json
end.to change(user.user_sessions, :count).by(-1)
expect(response).to have_http_status(:ok)
expect(user.reload.tokens.keys).not_to include('other-client')
end
it 'returns 422 when trying to revoke the current session' do
current = user.user_sessions.create!(client_id: current_client_id, last_activity_at: Time.current)
delete "/api/v1/profile/sessions/#{current.id}", headers: auth_headers, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to be_present
expect(user.user_sessions.exists?(id: current.id)).to be true
end
it 'returns 404 for a nonexistent session id' do
delete '/api/v1/profile/sessions/9999999', headers: auth_headers, as: :json
expect(response).to have_http_status(:not_found)
end
it 'does not allow revoking another user' do
other_user = create(:user, account: account)
foreign = other_user.user_sessions.create!(client_id: 'foreign', last_activity_at: 1.hour.ago)
delete "/api/v1/profile/sessions/#{foreign.id}", headers: auth_headers, as: :json
expect(response).to have_http_status(:not_found)
expect(other_user.user_sessions.exists?(id: foreign.id)).to be true
end
it 'returns 401 without auth' do
delete "/api/v1/profile/sessions/#{other_session.id}", as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
@@ -0,0 +1,90 @@
require 'rails_helper'
RSpec.describe UserSessionTrackingService do
let(:user) { create(:user) }
let(:client_id) { 'client-abc' }
let(:request) do
instance_double(
ActionDispatch::Request,
user_agent: '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',
remote_ip: '8.8.8.8'
)
end
let(:service) { described_class.new(user: user, request: request, client_id: client_id) }
let(:geo_result) { OpenStruct.new(city: 'Mountain View', country: 'United States', country_code: 'US') }
let(:ip_lookup) { instance_double(IpLookupService, perform: geo_result) }
before { allow(IpLookupService).to receive(:new).and_return(ip_lookup) }
describe '#create_or_update!' do
it 'creates a new UserSession with the right client_id and timestamps' do
expect { service.create_or_update! }.to change(user.user_sessions, :count).by(1)
session = user.user_sessions.last
expect(session.client_id).to eq(client_id)
expect(session.last_activity_at).to be_within(1.second).of(Time.current)
end
it 'populates request, browser, and geo metadata on the new session', :aggregate_failures do
service.create_or_update!
session = user.user_sessions.last
expect(session.ip_address).to eq('8.8.8.8')
expect(session.browser_name).to eq('Safari')
expect(session.platform_name).to eq('macOS')
expect(session.city).to eq('Mountain View')
expect(session.country).to eq('United States')
expect(session.country_code).to eq('US')
end
it 'updates an existing session when client_id matches' do
existing = user.user_sessions.create!(client_id: client_id, ip_address: '1.1.1.1', last_activity_at: 1.day.ago)
expect { service.create_or_update! }.not_to change(user.user_sessions, :count)
expect(existing.reload.ip_address).to eq('8.8.8.8')
expect(existing.last_activity_at).to be_within(1.second).of(Time.current)
end
it 'handles missing geo data gracefully' do
allow(ip_lookup).to receive(:perform).and_return(nil)
service.create_or_update!
session = user.user_sessions.last
expect(session.city).to be_nil
expect(session.country).to be_nil
expect(session.country_code).to be_nil
end
end
describe '#update_activity!' do
it 'does nothing when no session exists for the client_id' do
expect { service.update_activity! }.not_to change(user.user_sessions, :count)
end
it 'does nothing when the session was recently active' do
session = user.user_sessions.create!(client_id: client_id, last_activity_at: 1.minute.ago)
before_ts = session.last_activity_at
service.update_activity!
expect(session.reload.last_activity_at).to be_within(1.second).of(before_ts)
end
it 'bumps last_activity_at when the session is stale' do
session = user.user_sessions.create!(client_id: client_id, last_activity_at: 10.minutes.ago)
service.update_activity!
expect(session.reload.last_activity_at).to be_within(1.second).of(Time.current)
end
it 'bumps last_activity_at when last_activity_at is nil' do
session = user.user_sessions.create!(client_id: client_id, last_activity_at: nil)
service.update_activity!
expect(session.reload.last_activity_at).to be_within(1.second).of(Time.current)
end
end
end