Fixes #3520 - Make session TTL configurable.
This commit is contained in:
parent
062f19df3c
commit
8dc68ca1e4
8 changed files with 416 additions and 0 deletions
|
@ -0,0 +1,41 @@
|
||||||
|
class SessionTimeout extends App.Controller
|
||||||
|
constructor: ->
|
||||||
|
super
|
||||||
|
|
||||||
|
lastEvent = 0
|
||||||
|
check_timeout = =>
|
||||||
|
return if new Date().getTime() - 1000 < lastEvent
|
||||||
|
lastEvent = new Date().getTime()
|
||||||
|
@setDelay()
|
||||||
|
|
||||||
|
$(document).off('keyup.session_timeout').on('keyup.session_timeout', check_timeout)
|
||||||
|
$(document).off('mousemove.session_timeout').on('mousemove.session_timeout', check_timeout)
|
||||||
|
@controllerBind('config_update', check_timeout)
|
||||||
|
@controllerBind('session_timeout', @quitApp)
|
||||||
|
@setDelay()
|
||||||
|
|
||||||
|
setDelay: =>
|
||||||
|
return if App.Session.get() is undefined
|
||||||
|
@delay(@quitApp, @getTimeout(), 'session_timeout')
|
||||||
|
|
||||||
|
quitApp: =>
|
||||||
|
return if App.Session.get() is undefined
|
||||||
|
@navigate '#logout'
|
||||||
|
|
||||||
|
getTimeout: ->
|
||||||
|
user = App.User.find(App.Session.get().id)
|
||||||
|
config = App.Config.get('session_timeout')
|
||||||
|
|
||||||
|
timeout = -1
|
||||||
|
for key, value of config
|
||||||
|
continue if key is 'default'
|
||||||
|
continue if !user.permission(key)
|
||||||
|
continue if parseInt(value) < timeout
|
||||||
|
timeout = parseInt(value)
|
||||||
|
|
||||||
|
if timeout is -1
|
||||||
|
timeout = parseInt(config['default'])
|
||||||
|
|
||||||
|
return timeout * 1000
|
||||||
|
|
||||||
|
App.Config.set('session_timeout', SessionTimeout, 'Plugins')
|
51
app/jobs/session_timeout_job.rb
Normal file
51
app/jobs/session_timeout_job.rb
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
class SessionTimeoutJob < ApplicationJob
|
||||||
|
def perform
|
||||||
|
sessions.find_each do |session|
|
||||||
|
perform_session(session)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_session(session)
|
||||||
|
return if !session.data['user_id']
|
||||||
|
|
||||||
|
user = User.find(session.data['user_id'])
|
||||||
|
return if !user
|
||||||
|
|
||||||
|
timeout = get_timeout(user)
|
||||||
|
return if session.data['ping'] > Time.zone.now - timeout.seconds
|
||||||
|
|
||||||
|
self.class.destroy_session(user, session)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.destroy_session(user, session)
|
||||||
|
PushMessages.send_to(user.id, { event: 'session_timeout' })
|
||||||
|
session.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
def sessions
|
||||||
|
ActiveRecord::SessionStore::Session.where('updated_at < ?', Time.zone.now - config.values.map(&:to_i).min.seconds)
|
||||||
|
end
|
||||||
|
|
||||||
|
def config
|
||||||
|
Setting.get('session_timeout')
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_timeout(user)
|
||||||
|
permissions = Permission.where(id: user.permissions_with_child_ids).pluck(:name)
|
||||||
|
|
||||||
|
timeout = -1
|
||||||
|
config.each do |key, value|
|
||||||
|
next if key == 'default'
|
||||||
|
next if permissions.exclude?(key)
|
||||||
|
next if value.to_i < timeout
|
||||||
|
|
||||||
|
timeout = value.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
if timeout == -1
|
||||||
|
timeout = config['default'].to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
timeout
|
||||||
|
end
|
||||||
|
end
|
72
db/migrate/20210414000000_init_session_timeout.rb
Normal file
72
db/migrate/20210414000000_init_session_timeout.rb
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
class InitSessionTimeout < ActiveRecord::Migration[5.2]
|
||||||
|
def change
|
||||||
|
|
||||||
|
return if !Setting.exists?(name: 'system_init_done')
|
||||||
|
|
||||||
|
change_setting_prio('user_create_account', 10)
|
||||||
|
change_setting_prio('user_lost_password', 20)
|
||||||
|
|
||||||
|
two_days = 2.days.seconds
|
||||||
|
|
||||||
|
Setting.create_if_not_exists(
|
||||||
|
title: 'Session Timeout',
|
||||||
|
name: 'session_timeout',
|
||||||
|
area: 'Security::Base',
|
||||||
|
description: 'Defines the session timeout for inactivity of users (in seconds).',
|
||||||
|
options: {
|
||||||
|
form: [
|
||||||
|
{
|
||||||
|
display: 'Default',
|
||||||
|
null: false,
|
||||||
|
name: 'default',
|
||||||
|
tag: 'input',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: 'admin',
|
||||||
|
null: false,
|
||||||
|
name: 'admin',
|
||||||
|
tag: 'input',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: 'ticket.agent',
|
||||||
|
null: false,
|
||||||
|
name: 'ticket.agent',
|
||||||
|
tag: 'input',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: 'ticket.customer',
|
||||||
|
null: false,
|
||||||
|
name: 'ticket.customer',
|
||||||
|
tag: 'input',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
preferences: {
|
||||||
|
prio: 30,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
'default' => two_days,
|
||||||
|
'admin' => two_days,
|
||||||
|
'ticket.agent' => two_days,
|
||||||
|
'ticket.customer' => two_days,
|
||||||
|
},
|
||||||
|
frontend: true
|
||||||
|
)
|
||||||
|
|
||||||
|
Scheduler.create_or_update(
|
||||||
|
name: 'Cleanup dead sessions.',
|
||||||
|
method: 'SessionTimeoutJob.perform_now',
|
||||||
|
period: 1.minute,
|
||||||
|
prio: 2,
|
||||||
|
active: true,
|
||||||
|
updated_by_id: 1,
|
||||||
|
created_by_id: 1,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def change_setting_prio(name, prio)
|
||||||
|
setting = Setting.find_by(name: name)
|
||||||
|
setting.preferences[:prio] = prio
|
||||||
|
setting.save
|
||||||
|
end
|
||||||
|
end
|
|
@ -136,6 +136,15 @@ Scheduler.create_or_update(
|
||||||
updated_by_id: 1,
|
updated_by_id: 1,
|
||||||
created_by_id: 1,
|
created_by_id: 1,
|
||||||
)
|
)
|
||||||
|
Scheduler.create_or_update(
|
||||||
|
name: 'Cleanup dead sessions.',
|
||||||
|
method: 'SessionTimeoutJob.perform_now',
|
||||||
|
period: 1.minute,
|
||||||
|
prio: 2,
|
||||||
|
active: true,
|
||||||
|
updated_by_id: 1,
|
||||||
|
created_by_id: 1,
|
||||||
|
)
|
||||||
Scheduler.create_or_update(
|
Scheduler.create_or_update(
|
||||||
name: 'Sync calendars with ical feeds.',
|
name: 'Sync calendars with ical feeds.',
|
||||||
method: 'Calendar.sync',
|
method: 'Calendar.sync',
|
||||||
|
|
|
@ -1010,6 +1010,7 @@ Setting.create_if_not_exists(
|
||||||
},
|
},
|
||||||
state: true,
|
state: true,
|
||||||
preferences: {
|
preferences: {
|
||||||
|
prio: 10,
|
||||||
permission: ['admin.security'],
|
permission: ['admin.security'],
|
||||||
},
|
},
|
||||||
frontend: true
|
frontend: true
|
||||||
|
@ -1035,10 +1036,55 @@ Setting.create_if_not_exists(
|
||||||
},
|
},
|
||||||
state: true,
|
state: true,
|
||||||
preferences: {
|
preferences: {
|
||||||
|
prio: 20,
|
||||||
permission: ['admin.security'],
|
permission: ['admin.security'],
|
||||||
},
|
},
|
||||||
frontend: true
|
frontend: true
|
||||||
)
|
)
|
||||||
|
Setting.create_if_not_exists(
|
||||||
|
title: 'Session Timeout',
|
||||||
|
name: 'session_timeout',
|
||||||
|
area: 'Security::Base',
|
||||||
|
description: 'Defines the session timeout for inactivity of users (in seconds).',
|
||||||
|
options: {
|
||||||
|
form: [
|
||||||
|
{
|
||||||
|
display: 'Default',
|
||||||
|
null: false,
|
||||||
|
name: 'default',
|
||||||
|
tag: 'input',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: 'admin',
|
||||||
|
null: false,
|
||||||
|
name: 'admin',
|
||||||
|
tag: 'input',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: 'ticket.agent',
|
||||||
|
null: false,
|
||||||
|
name: 'ticket.agent',
|
||||||
|
tag: 'input',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: 'ticket.customer',
|
||||||
|
null: false,
|
||||||
|
name: 'ticket.customer',
|
||||||
|
tag: 'input',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
preferences: {
|
||||||
|
prio: 30,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
'default' => 2.days.seconds,
|
||||||
|
'admin' => 2.days.seconds,
|
||||||
|
'ticket.agent' => 2.days.seconds,
|
||||||
|
'ticket.customer' => 2.days.seconds,
|
||||||
|
},
|
||||||
|
frontend: true
|
||||||
|
)
|
||||||
Setting.create_if_not_exists(
|
Setting.create_if_not_exists(
|
||||||
title: 'User email for muliple users',
|
title: 'User email for muliple users',
|
||||||
name: 'user_email_multiple_use',
|
name: 'user_email_multiple_use',
|
||||||
|
|
19
spec/factories/active_record/session_store/session.rb
Normal file
19
spec/factories/active_record/session_store/session.rb
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
FactoryBot.define do
|
||||||
|
factory :'active_record/session_store/session', aliases: %i[active_session] do
|
||||||
|
transient do
|
||||||
|
user { create(:user) }
|
||||||
|
end
|
||||||
|
|
||||||
|
session_id { SecureRandom.hex(16) }
|
||||||
|
data do
|
||||||
|
{
|
||||||
|
'user_id' => user.id,
|
||||||
|
'ping' => Time.zone.now,
|
||||||
|
'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.128 Safari/537.36',
|
||||||
|
'_csrf_token' => 'Yq3XiEgXxWPCURa/FvpXmptZCjgWhyPpGGIvZj9Eea0='
|
||||||
|
}
|
||||||
|
end
|
||||||
|
created_at { Time.zone.now }
|
||||||
|
updated_at { Time.zone.now }
|
||||||
|
end
|
||||||
|
end
|
97
spec/jobs/session_timeout_job_spec.rb
Normal file
97
spec/jobs/session_timeout_job_spec.rb
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe SessionTimeoutJob, type: :job do
|
||||||
|
before do
|
||||||
|
create(:active_session, user: user)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with timeout admin' do
|
||||||
|
let(:user) { create(:admin) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
Setting.set('session_timeout', { admin: 30.minutes.to_s })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does kill the session' do
|
||||||
|
travel_to 1.hour.from_now
|
||||||
|
expect { described_class.perform_now }.to change(ActiveRecord::SessionStore::Session, :count).by(-1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not kill the session' do
|
||||||
|
travel_to 1.minute.from_now
|
||||||
|
expect { described_class.perform_now }.to change(ActiveRecord::SessionStore::Session, :count).by(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with timeout ticket.agent' do
|
||||||
|
let(:user) { create(:agent) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
Setting.set('session_timeout', { 'ticket.agent': 30.minutes.to_s })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does kill the session' do
|
||||||
|
travel_to 1.hour.from_now
|
||||||
|
expect { described_class.perform_now }.to change(ActiveRecord::SessionStore::Session, :count).by(-1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not kill the session' do
|
||||||
|
travel_to 1.minute.from_now
|
||||||
|
expect { described_class.perform_now }.to change(ActiveRecord::SessionStore::Session, :count).by(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with timeout ticket.customer' do
|
||||||
|
let(:user) { create(:customer) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
Setting.set('session_timeout', { 'ticket.customer': 30.minutes.to_s })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does kill the session' do
|
||||||
|
travel_to 1.hour.from_now
|
||||||
|
expect { described_class.perform_now }.to change(ActiveRecord::SessionStore::Session, :count).by(-1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not kill the session' do
|
||||||
|
travel_to 1.minute.from_now
|
||||||
|
expect { described_class.perform_now }.to change(ActiveRecord::SessionStore::Session, :count).by(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with timeout agent and customer' do
|
||||||
|
let(:user) { create(:agent_and_customer) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
Setting.set('session_timeout', { 'ticket.customer': 1.second.to_s, 'ticket.agent': 2.hours.to_s })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does kill the session' do
|
||||||
|
travel_to 1.day.from_now
|
||||||
|
expect { described_class.perform_now }.to change(ActiveRecord::SessionStore::Session, :count).by(-1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not kill the session' do
|
||||||
|
travel_to 1.hour.from_now
|
||||||
|
expect { described_class.perform_now }.to change(ActiveRecord::SessionStore::Session, :count).by(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with timeout default' do
|
||||||
|
let(:user) { create(:customer) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
Setting.set('session_timeout', { default: 30.minutes.to_s })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does kill the session' do
|
||||||
|
travel_to 1.hour.from_now
|
||||||
|
expect { described_class.perform_now }.to change(ActiveRecord::SessionStore::Session, :count).by(-1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not kill the session' do
|
||||||
|
travel_to 1.minute.from_now
|
||||||
|
expect { described_class.perform_now }.to change(ActiveRecord::SessionStore::Session, :count).by(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -32,4 +32,85 @@ RSpec.describe 'Dashboard', type: :system, authenticated_as: true do
|
||||||
expect(User.find_by(firstname: 'Nick').roles).to eq([Role.find_by(name: 'Public')])
|
expect(User.find_by(firstname: 'Nick').roles).to eq([Role.find_by(name: 'Public')])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'Session Timeout' do
|
||||||
|
let(:admin) { create(:admin) }
|
||||||
|
let(:agent) { create(:agent) }
|
||||||
|
let(:customer) { create(:customer) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
ensure_websocket(check_if_pinged: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'Logout by frontend plugin - Default', authenticated_as: :authenticate do
|
||||||
|
def authenticate
|
||||||
|
Setting.set('session_timeout', { default: '1' })
|
||||||
|
admin
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does logout user' do
|
||||||
|
expect(page).to have_text('Sign in', wait: 15)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not logout user', authenticated_as: :admin do
|
||||||
|
sleep 1.5
|
||||||
|
expect(page).to have_no_text('Sign in', wait: 0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'Logout by frontend plugin - Setting change', authenticated_as: :admin do
|
||||||
|
it 'does logout user' do
|
||||||
|
expect(page).to have_no_text('Sign in')
|
||||||
|
Setting.set('session_timeout', { default: '1' })
|
||||||
|
expect(page).to have_text('Sign in', wait: 15)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'Logout by frontend plugin - Admin', authenticated_as: :authenticate do
|
||||||
|
def authenticate
|
||||||
|
Setting.set('session_timeout', { admin: '1', default: '1000' })
|
||||||
|
admin
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does logout user' do
|
||||||
|
expect(page).to have_text('Sign in', wait: 15)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'Logout by frontend plugin - Agent', authenticated_as: :authenticate do
|
||||||
|
def authenticate
|
||||||
|
Setting.set('session_timeout', { 'ticket.agent': '1', default: '1000' })
|
||||||
|
agent
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does logout user' do
|
||||||
|
expect(page).to have_text('Sign in', wait: 15)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'Logout by frontend plugin - Customer', authenticated_as: :authenticate do
|
||||||
|
def authenticate
|
||||||
|
Setting.set('session_timeout', { 'ticket.customer': '1', default: '1000' })
|
||||||
|
customer
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does logout user' do
|
||||||
|
expect(page).to have_text('Sign in', wait: 15)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'Logout by SessionTimeoutJob - destroy_session' do
|
||||||
|
it 'does logout user', authenticated_as: :admin do
|
||||||
|
|
||||||
|
# because of the websocket server running in the same
|
||||||
|
# process and the checks in the frontend it is really
|
||||||
|
# hard test the SessionTimeoutJob.perform_now here
|
||||||
|
# so we only check the session killing code and use
|
||||||
|
# backend tests for the rest
|
||||||
|
session = ActiveRecord::SessionStore::Session.all.detect { |s| s.data['user_id'] == admin.id }
|
||||||
|
SessionTimeoutJob.destroy_session(admin, session)
|
||||||
|
expect(page).to have_text('Sign in', wait: 15)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in a new issue