## 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
184 lines
5.8 KiB
Ruby
184 lines
5.8 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe DeviseOverrides::SessionsController, type: :controller do
|
|
include Devise::Test::ControllerHelpers
|
|
|
|
before do
|
|
request.env['devise.mapping'] = Devise.mappings[:user]
|
|
end
|
|
|
|
describe 'POST #create' do
|
|
let(:user) { create(:user, password: 'Test@123456') }
|
|
|
|
context 'with standard authentication' do
|
|
it 'authenticates with valid credentials' do
|
|
post :create, params: { email: user.email, password: 'Test@123456' }
|
|
|
|
expect(response).to have_http_status(:success)
|
|
end
|
|
|
|
it 'rejects invalid credentials' do
|
|
post :create, params: { email: user.email, password: 'wrong' }
|
|
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'with MFA authentication' do
|
|
before do
|
|
skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
|
|
user.enable_two_factor!
|
|
user.update!(otp_required_for_login: true)
|
|
end
|
|
|
|
it 'requires MFA verification after successful password authentication' do
|
|
post :create, params: { email: user.email, password: 'Test@123456' }
|
|
|
|
expect(response).to have_http_status(:partial_content)
|
|
json_response = response.parsed_body
|
|
expect(json_response['mfa_required']).to be(true)
|
|
expect(json_response['mfa_token']).to be_present
|
|
end
|
|
|
|
it 'does not return authentication tokens before MFA verification' do
|
|
post :create, params: { email: user.email, password: 'Test@123456' }
|
|
|
|
expect(response).to have_http_status(:partial_content)
|
|
|
|
# Check that no authentication headers are present
|
|
expect(response.headers['access-token']).to be_nil
|
|
expect(response.headers['uid']).to be_nil
|
|
expect(response.headers['client']).to be_nil
|
|
expect(response.headers['Authorization']).to be_nil
|
|
|
|
# Check that no bearer token is present in any form
|
|
response.headers.each do |key, value|
|
|
expect(value.to_s).not_to include('Bearer') if key.downcase.include?('auth')
|
|
end
|
|
|
|
json_response = response.parsed_body
|
|
expect(json_response['data']).to be_nil
|
|
end
|
|
|
|
context 'when verifying MFA' do
|
|
let(:mfa_token) { Mfa::TokenService.new(user: user).generate_token }
|
|
|
|
it 'authenticates with valid OTP' do
|
|
post :create, params: {
|
|
mfa_token: mfa_token,
|
|
otp_code: user.current_otp
|
|
}
|
|
|
|
expect(response).to have_http_status(:success)
|
|
end
|
|
|
|
it 'authenticates with valid backup code' do
|
|
backup_codes = user.generate_backup_codes!
|
|
|
|
post :create, params: {
|
|
mfa_token: mfa_token,
|
|
backup_code: backup_codes.first
|
|
}
|
|
|
|
expect(response).to have_http_status(:success)
|
|
end
|
|
|
|
it 'rejects invalid OTP' do
|
|
post :create, params: {
|
|
mfa_token: mfa_token,
|
|
otp_code: '000000'
|
|
}
|
|
|
|
expect(response).to have_http_status(:bad_request)
|
|
expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_code'))
|
|
end
|
|
|
|
it 'rejects invalid backup code' do
|
|
user.generate_backup_codes!
|
|
|
|
post :create, params: {
|
|
mfa_token: mfa_token,
|
|
backup_code: 'invalid'
|
|
}
|
|
|
|
expect(response).to have_http_status(:bad_request)
|
|
expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_code'))
|
|
end
|
|
|
|
it 'rejects expired MFA token' do
|
|
expired_token = JWT.encode(
|
|
{ user_id: user.id, exp: 1.minute.ago.to_i },
|
|
Rails.application.secret_key_base,
|
|
'HS256'
|
|
)
|
|
|
|
post :create, params: {
|
|
mfa_token: expired_token,
|
|
otp_code: user.current_otp
|
|
}
|
|
|
|
expect(response).to have_http_status(:unauthorized)
|
|
expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_token'))
|
|
end
|
|
|
|
it 'requires either OTP or backup code' do
|
|
post :create, params: { mfa_token: mfa_token }
|
|
|
|
expect(response).to have_http_status(:bad_request)
|
|
expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_code'))
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'with SSO authentication' do
|
|
it 'authenticates with valid SSO token' do
|
|
sso_token = user.generate_sso_auth_token
|
|
|
|
post :create, params: {
|
|
email: user.email,
|
|
sso_auth_token: sso_token
|
|
}
|
|
|
|
expect(response).to have_http_status(:success)
|
|
end
|
|
|
|
it 'rejects invalid SSO token' do
|
|
post :create, params: {
|
|
email: user.email,
|
|
sso_auth_token: 'invalid'
|
|
}
|
|
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'GET #new' do
|
|
it 'redirects to frontend login page' do
|
|
allow(ENV).to receive(:fetch).and_call_original
|
|
allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('/frontend')
|
|
|
|
get :new
|
|
|
|
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
|