feat: manage active user sessions from profile (CW-7169) (#14556)

## Description

First PR of user_sessions feature - enforcement, impersonation and mfa will be handled separately.

Adds an Active Sessions section under Profile where users can see every
device currently logged in and revoke any session they don't recognize.
Helps users lock down stale or unrecognized logins on their own without
needing support.

**Behavior at the limit, by client:**
- **Browser:** returns 409 with a picker overlay; user picks a session
to revoke or chooses "End all sessions" to clear them.

- **Mobile / API client:** silently evicts the oldest session and
proceeds with login (no picker UI to render).
- **Pre-tracking users** (token rows without `user_sessions`, i.e.
anyone already logged in before this ships): silent-evict any untracked
token first, so freshly tracked sessions are never killed in favor of
legacy ones.

Sessions are stored in a new `user_sessions` table keyed on `(user_id,
client_id)` with browser, platform, IP, last activity and (when
configured) geo. Kept in sync with `user.tokens` via an after_save
callback so revoking a token from any path cleans up the row.

Fixes https://linear.app/chatwoot/issue/CW-7169

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

- Added specs.
- Manual local testing: browser picker fires at limit; pre-tracking user
silent-evicts; mixed tracked/untracked correctly drops the untracked one
first; profile page revoke succeeds; current session cannot be revoked
from profile.

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
This commit is contained in:
Vishnu Narayanan
2026-06-12 16:11:07 +05:30
committed by GitHub
parent e055cead35
commit 92a1fb8ab7
24 changed files with 754 additions and 1 deletions
@@ -0,0 +1,36 @@
class Api::V1::Profile::SessionsController < Api::BaseController
before_action :set_session, only: [:destroy]
def index
@sessions = current_user.user_sessions.where(client_id: active_token_client_ids).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
def active_token_client_ids
now = Time.current.to_i
(current_user.tokens || {}).select { |_, v| v['expiry'].to_i > now }.keys
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
@@ -20,6 +20,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def render_create_success
track_user_session
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
end
@@ -114,6 +115,19 @@ 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 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}`);
},
};
@@ -154,6 +154,10 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
SHARE_CLICKED: 'Year in Review: Share clicked',
});
export const SESSION_EVENTS = Object.freeze({
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',
@@ -86,6 +86,17 @@
"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",
"UNKNOWN_DEVICE": "Unknown device"
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
@@ -0,0 +1,137 @@
<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('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.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="ruby"
@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
+18
View File
@@ -0,0 +1,18 @@
class UserSessionIpLookupJob < ApplicationJob
queue_as :low
def perform(session)
return if session.ip_address.blank?
result = IpLookupService.new.perform(session.ip_address)
return unless result
session.update_columns( # rubocop:disable Rails/SkipsModelValidations
city: result.city,
country: result.country,
country_code: result.country_code
)
rescue StandardError => e
Rails.logger.warn "UserSessionIpLookupJob failed: #{e.message}"
end
end
+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,39 @@
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!
UserSessionIpLookupJob.perform_later(session) if session.ip_address.present?
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)
{
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
}
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
+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
@@ -437,6 +437,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
+22 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) do
ActiveRecord::Schema[7.1].define(version: 2026_06_11_184600) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -1253,6 +1253,26 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) 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
@@ -1325,6 +1345,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) 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,21 @@ RSpec.describe DeviseOverrides::SessionsController, type: :controller do
expect(response).to redirect_to('/frontend/app/login?error=access-denied')
end
end
describe 'session tracking' 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' }
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)
session = user.user_sessions.last
expect(session.browser_name).to eq('Safari')
expect(session.platform_name).to eq('macOS')
end
end
end
end
@@ -0,0 +1,45 @@
require 'rails_helper'
RSpec.describe UserSessionIpLookupJob do
let(:user) { create(:user) }
let(:session) { user.user_sessions.create!(client_id: 'c', ip_address: '8.8.8.8', last_activity_at: Time.current) }
let(:geo_result) { OpenStruct.new(city: 'Mountain View', country: 'United States', country_code: 'US') }
let(:ip_lookup) { instance_double(IpLookupService) }
before { allow(IpLookupService).to receive(:new).and_return(ip_lookup) }
it 'backfills geo data on the session' do
allow(ip_lookup).to receive(:perform).with('8.8.8.8').and_return(geo_result)
described_class.perform_now(session)
session.reload
expect(session.city).to eq('Mountain View')
expect(session.country).to eq('United States')
expect(session.country_code).to eq('US')
end
it 'is a no-op when ip_address is blank' do
session.update_columns(ip_address: nil) # rubocop:disable Rails/SkipsModelValidations
described_class.perform_now(session)
expect(IpLookupService).not_to have_received(:new)
end
it 'leaves the session untouched when lookup returns nil' do
allow(ip_lookup).to receive(:perform).and_return(nil)
described_class.perform_now(session)
session.reload
expect(session.city).to be_nil
expect(session.country).to be_nil
end
it 'swallows lookup errors so a flaky geocoder does not poison the queue' do
allow(ip_lookup).to receive(:perform).and_raise(StandardError.new('boom'))
expect { described_class.perform_now(session) }.not_to raise_error
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,101 @@
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)
user.update!(tokens: user.tokens.merge('other-client' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i }))
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 'excludes sessions whose token has expired' do
live = user.user_sessions.create!(client_id: current_client_id, last_activity_at: 1.hour.ago)
expired = user.user_sessions.create!(client_id: 'expired-client', last_activity_at: 1.day.ago)
user.update!(tokens: user.tokens.merge('expired-client' => { 'token' => 'x', 'expiry' => 1.day.ago.to_i }))
get '/api/v1/profile/sessions', headers: auth_headers, as: :json
expect(response).to have_http_status(:success)
ids = response.parsed_body.map { |s| s['id'] }
expect(ids).to include(live.id)
expect(ids).not_to include(expired.id)
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,82 @@
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) }
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 and browser metadata synchronously', :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')
end
it 'does not call IpLookupService synchronously' do
expect(IpLookupService).not_to receive(:new)
service.create_or_update!
end
it 'enqueues UserSessionIpLookupJob to backfill geo data' do
expect { service.create_or_update! }.to have_enqueued_job(UserSessionIpLookupJob)
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
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