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:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user