Files
Vishnu NarayananandGitHub 92a1fb8ab7 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
2026-06-12 16:11:07 +05:30

291 lines
9.3 KiB
Ruby

# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/access_tokenable_shared.rb'
require Rails.root.join 'spec/models/concerns/avatarable_shared.rb'
RSpec.describe User do
let!(:user) { create(:user) }
context 'with validations' do
it { is_expected.to validate_presence_of(:email) }
end
context 'with associations' do
it { is_expected.to have_many(:accounts).through(:account_users) }
it { is_expected.to have_many(:account_users) }
it { is_expected.to have_many(:assigned_conversations).dependent(:nullify) }
it { is_expected.to have_many(:inbox_members).dependent(:destroy_async) }
it { is_expected.to have_many(:notification_settings).dependent(:destroy_async) }
it { is_expected.to have_many(:messages) }
it { is_expected.to have_many(:reporting_events) }
it { is_expected.to have_many(:teams) }
end
describe 'concerns' do
it_behaves_like 'access_tokenable'
it_behaves_like 'avatarable'
end
describe 'pubsub_token' do
before { user.update(name: Faker::Name.name) }
it { expect(user.pubsub_token).not_to be_nil }
it { expect(user.saved_changes.keys).not_to eq('pubsub_token') }
context 'with rotate the pubsub_token' do
it 'changes the pubsub_token when password changes' do
pubsub_token = user.pubsub_token
user.password = Faker::Internet.password(special_characters: true)
user.save!
expect(user.pubsub_token).not_to eq(pubsub_token)
end
it 'will not change pubsub_token when other attributes change' do
pubsub_token = user.pubsub_token
user.name = Faker::Name.name
user.save!
expect(user.pubsub_token).to eq(pubsub_token)
end
end
end
describe 'hmac_identifier' do
it 'return nil if CHATWOOT_INBOX_HMAC_KEY is not set' do
expect(user.hmac_identifier).to eq('')
end
it 'return value if CHATWOOT_INBOX_HMAC_KEY is set' do
ConfigLoader.new.process
i = InstallationConfig.find_by(name: 'CHATWOOT_INBOX_HMAC_KEY')
i.value = 'random_secret_key'
i.save!
GlobalConfig.clear_cache
expected_hmac_identifier = OpenSSL::HMAC.hexdigest('sha256', 'random_secret_key', user.email)
expect(user.hmac_identifier).to eq expected_hmac_identifier
end
end
context 'with sso_auth_token' do
it 'can generate multiple sso tokens which can be validated' do
sso_auth_token1 = user.generate_sso_auth_token
sso_auth_token2 = user.generate_sso_auth_token
expect(sso_auth_token1).present?
expect(sso_auth_token2).present?
expect(user.valid_sso_auth_token?(sso_auth_token1)).to be true
expect(user.valid_sso_auth_token?(sso_auth_token2)).to be true
end
it 'wont validate an invalid token' do
expect(user.valid_sso_auth_token?(SecureRandom.hex(32))).to be false
end
it 'wont validate an invalidated token' do
sso_auth_token = user.generate_sso_auth_token
user.invalidate_sso_auth_token(sso_auth_token)
expect(user.valid_sso_auth_token?(sso_auth_token)).to be false
end
end
describe 'access token' do
it 'creates a single access token upon user creation' do
new_user = create(:user)
token_count = AccessToken.where(owner: new_user).count
expect(token_count).to eq(1)
end
end
context 'when user changes the email' do
it 'mutates the value' do
user.email = 'user@example.com'
expect(user.will_save_change_to_email?).to be true
end
end
context 'when the supplied email is uppercase' do
it 'downcases the email on save' do
new_user = create(:user, email: 'Test123@test.com')
expect(new_user.email).to eq('test123@test.com')
end
end
describe '2FA/MFA functionality' do
before do
skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
end
let(:user) { create(:user, password: 'Test@123456') }
describe '#enable_two_factor!' do
it 'generates OTP secret for 2FA setup' do
expect(user.otp_secret).to be_nil
expect(user.otp_required_for_login).to be_falsey
user.enable_two_factor!
expect(user.otp_secret).not_to be_nil
# otp_required_for_login is false until verification is complete
expect(user.otp_required_for_login).to be_falsey
end
end
describe '#disable_two_factor!' do
before do
user.enable_two_factor!
user.update!(otp_required_for_login: true) # Simulate verified 2FA
user.generate_backup_codes!
end
it 'disables 2FA and clears OTP secret' do
user.disable_two_factor!
expect(user.otp_secret).to be_nil
expect(user.otp_required_for_login).to be_falsey
expect(user.otp_backup_codes).to be_blank # Can be nil or empty array
end
end
describe '#generate_backup_codes!' do
before do
user.enable_two_factor!
end
it 'generates 10 backup codes' do
codes = user.generate_backup_codes!
expect(codes).to be_an(Array)
expect(codes.length).to eq(10)
expect(codes.first).to match(/\A[A-F0-9]{8}\z/) # 8-character hex codes
expect(user.otp_backup_codes).not_to be_nil
end
end
describe '#two_factor_provisioning_uri' do
before do
user.enable_two_factor!
end
it 'generates a valid provisioning URI for QR code' do
uri = user.two_factor_provisioning_uri
expect(uri).to include('otpauth://totp/')
expect(uri).to include(CGI.escape(user.email))
expect(uri).to include('Chatwoot')
end
end
describe '#validate_backup_code!' do
let(:backup_codes) { user.generate_backup_codes! }
before do
user.enable_two_factor!
backup_codes
end
it 'validates and invalidates correct backup code' do
code = backup_codes.first
result = user.validate_backup_code!(code)
expect(result).to be_truthy
# Verify it's marked as used
user.reload
expect(user.otp_backup_codes).to include('XXXXXXXX')
end
it 'rejects invalid backup code' do
result = user.validate_backup_code!('invalid')
expect(result).to be_falsey
end
it 'rejects already used backup code' do
code = backup_codes.first
user.validate_backup_code!(code)
# Try to use the same code again
result = user.validate_backup_code!(code)
expect(result).to be_falsey
end
it 'handles blank code' do
result = user.validate_backup_code!(nil)
expect(result).to be_falsey
result = user.validate_backup_code!('')
expect(result).to be_falsey
end
end
end
describe '#active_account_user' do
let(:user) { create(:user) }
let(:account1) { create(:account) }
let(:account2) { create(:account) }
let(:account3) { create(:account) }
before do
# Create account_users with different active_at values
create(:account_user, user: user, account: account1, active_at: 2.days.ago)
create(:account_user, user: user, account: account2, active_at: 1.day.ago)
create(:account_user, user: user, account: account3, active_at: nil) # New account with NULL active_at
end
it 'returns the account_user with the most recent active_at, prioritizing timestamps over NULL values' do
# Should return account2 (most recent timestamp) even though account3 was created last with NULL active_at
expect(user.active_account_user.account_id).to eq(account2.id)
end
it 'returns NULL active_at account only when no other accounts have active_at' do
# Remove active_at from all accounts
user.account_users.each { |au| au.update!(active_at: nil) }
# Should return one of the accounts (behavior is undefined but consistent)
expect(user.active_account_user).to be_present
end
context 'when multiple accounts have NULL active_at' do
before do
create(:account_user, user: user, account: create(:account), active_at: nil)
end
it 'still prioritizes accounts with timestamps' do
expect(user.active_account_user.account_id).to eq(account2.id)
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