mirror of
https://github.com/ansible/awx.git
synced 2024-10-31 23:51:09 +03:00
Merge pull request #2332 from ryanpetrello/fix-session-limit
make settings.SESSIONS_PER_USER work
This commit is contained in:
commit
39bc64d089
@ -6,6 +6,7 @@ import contextlib
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import json
|
import json
|
||||||
|
import pkg_resources
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
@ -18,6 +19,7 @@ from django.db.models.signals import (
|
|||||||
)
|
)
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.contrib.auth import SESSION_KEY
|
from django.contrib.auth import SESSION_KEY
|
||||||
|
from django.contrib.sessions.models import Session
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
@ -29,7 +31,6 @@ import six
|
|||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.main.models import * # noqa
|
from awx.main.models import * # noqa
|
||||||
from django.contrib.sessions.models import Session
|
|
||||||
from awx.api.serializers import * # noqa
|
from awx.api.serializers import * # noqa
|
||||||
from awx.main.constants import TOKEN_CENSOR
|
from awx.main.constants import TOKEN_CENSOR
|
||||||
from awx.main.utils import model_instance_diff, model_to_dict, camelcase_to_underscore, get_current_apps
|
from awx.main.utils import model_instance_diff, model_to_dict, camelcase_to_underscore, get_current_apps
|
||||||
@ -600,6 +601,16 @@ def delete_inventory_for_org(sender, instance, **kwargs):
|
|||||||
@receiver(post_save, sender=Session)
|
@receiver(post_save, sender=Session)
|
||||||
def save_user_session_membership(sender, **kwargs):
|
def save_user_session_membership(sender, **kwargs):
|
||||||
session = kwargs.get('instance', None)
|
session = kwargs.get('instance', None)
|
||||||
|
if pkg_resources.get_distribution('channels').version >= '2':
|
||||||
|
# If you get into this code block, it means we upgraded channels, but
|
||||||
|
# didn't make the settings.SESSIONS_PER_USER feature work
|
||||||
|
raise RuntimeError(
|
||||||
|
'save_user_session_membership must be updated for channels>=2: '
|
||||||
|
'http://channels.readthedocs.io/en/latest/one-to-two.html#requirements'
|
||||||
|
)
|
||||||
|
if 'runworker' in sys.argv:
|
||||||
|
# don't track user session membership for websocket per-channel sessions
|
||||||
|
return
|
||||||
if not session:
|
if not session:
|
||||||
return
|
return
|
||||||
user = session.get_decoded().get(SESSION_KEY, None)
|
user = session.get_decoded().get(SESSION_KEY, None)
|
||||||
@ -608,13 +619,15 @@ def save_user_session_membership(sender, **kwargs):
|
|||||||
user = User.objects.get(pk=user)
|
user = User.objects.get(pk=user)
|
||||||
if UserSessionMembership.objects.filter(user=user, session=session).exists():
|
if UserSessionMembership.objects.filter(user=user, session=session).exists():
|
||||||
return
|
return
|
||||||
UserSessionMembership.objects.create(user=user, session=session, created=timezone.now())
|
UserSessionMembership(user=user, session=session, created=timezone.now()).save()
|
||||||
for membership in UserSessionMembership.get_memberships_over_limit(user):
|
expired = UserSessionMembership.get_memberships_over_limit(user)
|
||||||
|
for membership in expired:
|
||||||
|
Session.objects.filter(session_key__in=[membership.session_id]).delete()
|
||||||
|
membership.delete()
|
||||||
|
if len(expired):
|
||||||
consumers.emit_channel_notification(
|
consumers.emit_channel_notification(
|
||||||
'control-limit_reached',
|
'control-limit_reached_{}'.format(user.pk),
|
||||||
dict(group_name='control',
|
dict(group_name='control', reason=unicode(_('limit_reached')))
|
||||||
reason=unicode(_('limit_reached')),
|
|
||||||
session_key=membership.session.session_key)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
|
from importlib import import_module
|
||||||
import pytest
|
import pytest
|
||||||
from datetime import timedelta
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from django.utils.timezone import now as tz_now
|
from django.conf import settings
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
from django.contrib.sessions.middleware import SessionMiddleware
|
from django.contrib.sessions.middleware import SessionMiddleware
|
||||||
from django.contrib.sessions.models import Session
|
from django.contrib.sessions.models import Session
|
||||||
from django.contrib.auth import SESSION_KEY
|
from django.contrib.auth import SESSION_KEY
|
||||||
|
import mock
|
||||||
|
|
||||||
from awx.main.models import UserSessionMembership
|
|
||||||
from awx.api.versioning import reverse
|
from awx.api.versioning import reverse
|
||||||
|
|
||||||
|
|
||||||
@ -63,33 +63,40 @@ def test_session_create_delete(admin, post, get):
|
|||||||
assert not Session.objects.filter(session_key=session_key).exists()
|
assert not Session.objects.filter(session_key=session_key).exists()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason="Needs Update - CA")
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_session_overlimit(admin, post):
|
@mock.patch('awx.main.consumers.emit_channel_notification')
|
||||||
AlwaysPassBackend.user = admin
|
def test_sessions_unlimited(emit, admin):
|
||||||
with override_settings(
|
assert Session.objects.count() == 0
|
||||||
AUTHENTICATION_BACKENDS=(AlwaysPassBackend.get_backend_path(),),
|
for i in range(5):
|
||||||
SESSION_COOKIE_NAME='session_id', SESSIONS_PER_USER=3
|
store = import_module(settings.SESSION_ENGINE).SessionStore()
|
||||||
):
|
store.create_model_instance({SESSION_KEY: admin.pk}).save()
|
||||||
sessions_to_deprecate = []
|
assert Session.objects.count() == i + 1
|
||||||
for _ in range(5):
|
assert emit.call_count == 0
|
||||||
response = post(
|
|
||||||
'/api/login/',
|
|
||||||
data={'username': admin.username, 'password': admin.password, 'next': '/api/'},
|
@pytest.mark.django_db
|
||||||
expect=302, middleware=SessionMiddleware(), format='multipart'
|
@mock.patch('awx.main.consumers.emit_channel_notification')
|
||||||
)
|
def test_session_overlimit(emit, admin, alice):
|
||||||
session_key = re.findall(
|
# If SESSIONS_PER_USER=3, only persist the three most recently created sessions
|
||||||
r'session_id=[a-zA-z0-9]+',
|
assert Session.objects.count() == 0
|
||||||
str(response.cookies['session_id'])
|
with override_settings(SESSIONS_PER_USER=3):
|
||||||
)[0][len('session_id=') :]
|
created = []
|
||||||
sessions_to_deprecate.append(Session.objects.get(session_key=session_key))
|
for i in range(5):
|
||||||
sessions_to_deprecate[0].expire_date = tz_now() - timedelta(seconds=1000)
|
store = import_module(settings.SESSION_ENGINE).SessionStore()
|
||||||
sessions_to_deprecate[0].save()
|
session = store.create_model_instance({SESSION_KEY: admin.pk})
|
||||||
sessions_overlimit = [x.session for x in UserSessionMembership.get_memberships_over_limit(admin)]
|
session.save()
|
||||||
assert sessions_to_deprecate[0] not in sessions_overlimit
|
created.append(session.session_key)
|
||||||
assert sessions_to_deprecate[1] in sessions_overlimit
|
assert [s.pk for s in Session.objects.all()] == created[-3:]
|
||||||
for session in sessions_to_deprecate[2 :]:
|
assert emit.call_count == 2 # 2 of 5 sessions were evicted
|
||||||
assert session not in sessions_overlimit
|
emit.assert_called_with(
|
||||||
|
'control-limit_reached_{}'.format(admin.pk),
|
||||||
|
{'reason': 'limit_reached', 'group_name': 'control'}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Allow sessions for a different user to be saved
|
||||||
|
store = import_module(settings.SESSION_ENGINE).SessionStore()
|
||||||
|
store.create_model_instance({SESSION_KEY: alice.pk}).save()
|
||||||
|
assert Session.objects.count() == 4
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason="Needs Update - CA")
|
@pytest.mark.skip(reason="Needs Update - CA")
|
||||||
|
@ -5,8 +5,8 @@
|
|||||||
*************************************************/
|
*************************************************/
|
||||||
import ReconnectingWebSocket from 'reconnectingwebsocket';
|
import ReconnectingWebSocket from 'reconnectingwebsocket';
|
||||||
export default
|
export default
|
||||||
['$rootScope', '$location', '$log','$state', '$q', 'i18n',
|
['$rootScope', '$location', '$log','$state', '$q', 'i18n', 'GetBasePath', 'Rest',
|
||||||
function ($rootScope, $location, $log, $state, $q, i18n) {
|
function ($rootScope, $location, $log, $state, $q, i18n, GetBasePath, Rest) {
|
||||||
var needsResubscribing = false,
|
var needsResubscribing = false,
|
||||||
socketPromise = $q.defer(),
|
socketPromise = $q.defer(),
|
||||||
needsRefreshAfterBlur;
|
needsRefreshAfterBlur;
|
||||||
@ -130,13 +130,20 @@ export default
|
|||||||
else if(data.group_name==="inventory_update_events"){
|
else if(data.group_name==="inventory_update_events"){
|
||||||
str = `ws-${data.group_name}-${data.inventory_update}`;
|
str = `ws-${data.group_name}-${data.inventory_update}`;
|
||||||
}
|
}
|
||||||
else if(data.group_name==="control"){
|
else if(data.group_name === "control" && data.reason === "limit_reached"){
|
||||||
// As of v. 3.1.0, there is only 1 "control"
|
// If we got a `limit_reached_<user_pk>` message, determine
|
||||||
// message, which is for expiring the session if the
|
// if the current session is still valid (it may have been
|
||||||
// session limit is breached.
|
// invalidated)
|
||||||
|
// If so, log the user out and show a meaningful error
|
||||||
$log.debug(data.reason);
|
$log.debug(data.reason);
|
||||||
$rootScope.sessionTimer.expireSession('session_limit');
|
let url = GetBasePath('me');
|
||||||
$state.go('signOut');
|
Rest.get(url)
|
||||||
|
.catch(function(resp) {
|
||||||
|
if (resp.status === 401) {
|
||||||
|
$rootScope.sessionTimer.expireSession('session_limit');
|
||||||
|
$state.go('signOut');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// The naming scheme is "ws" then a
|
// The naming scheme is "ws" then a
|
||||||
@ -158,6 +165,7 @@ export default
|
|||||||
// listen for specific messages. A subscription object could
|
// listen for specific messages. A subscription object could
|
||||||
// look like {"groups":{"jobs": ["status_changed", "summary"]}.
|
// look like {"groups":{"jobs": ["status_changed", "summary"]}.
|
||||||
// This is used by all socket-enabled $states
|
// This is used by all socket-enabled $states
|
||||||
|
state.data.socket.groups.control = ['limit_reached_' + $rootScope.current_user.id];
|
||||||
this.emit(JSON.stringify(state.data.socket));
|
this.emit(JSON.stringify(state.data.socket));
|
||||||
this.setLast(state);
|
this.setLast(state);
|
||||||
},
|
},
|
||||||
|
@ -27,7 +27,7 @@ Once you've connected, you are not subscribed to any event groups. You subscribe
|
|||||||
'project_update_events': [ids...],
|
'project_update_events': [ids...],
|
||||||
'inventory_update_events': [ids...],
|
'inventory_update_events': [ids...],
|
||||||
'system_job_events': [ids...],
|
'system_job_events': [ids...],
|
||||||
'control': ['limit_reached'],
|
'control': ['limit_reached_<user_id>'],
|
||||||
}
|
}
|
||||||
|
|
||||||
These map to the event group and event type you are interested in. Sending in a new groups dictionary will clear all of your previously
|
These map to the event group and event type you are interested in. Sending in a new groups dictionary will clear all of your previously
|
||||||
|
Loading…
Reference in New Issue
Block a user