Add active sessions web page

Fixes: https://pagure.io/koji/issue/3396
This commit is contained in:
Jana Cupova 2022-06-29 14:09:26 +02:00 committed by Tomas Kopecek
parent b04ce33fa0
commit 7897628159
9 changed files with 265 additions and 12 deletions

View file

@ -10257,11 +10257,54 @@ class RootExports(object):
"""Return string representation of session for current user"""
return "%s" % context.session
def getSessionInfo(self):
"""Return session info for current user"""
def getSessionInfo(self, details=False, user_id=None):
"""Return session info for current user or all not expired sessions to specific user
:param boolean details: add session ID and hostip to result
:param str user_id: show all not expired sessions related to specific user
:returns: dict or list of dicts session data
"""
if not context.session.logged_in:
return None
return context.session.session_data
clauses = ['expired is FALSE']
fields = {'user_id': 'user_id',
'expired': 'expired',
'master': 'master',
'authtype': 'authtype',
'callnum': 'callnum',
"date_part('epoch', start_time)": 'start_time',
'update_time': 'update_time',
'exclusive': 'exclusive',
}
columns, aliases = zip(*fields.items())
if details:
columns += ('hostip', 'id')
aliases += ('hostip', 'id')
if user_id:
user_id = get_user(user_id, strict=True)['id']
logged_user_id = self.getLoggedInUser()['id']
if not context.session.hasPerm('admin') and user_id != logged_user_id:
raise koji.ActionNotAllowed('only admins or owners may see all active sessions')
clauses.append('user_id = %(user_id)i')
else:
result = context.session.session_data
if details:
id = context.session.id
clauses.append('id = %(id)i')
else:
return result
query = QueryProcessor(tables=['sessions'],
columns=columns, aliases=aliases,
clauses=clauses,
values=locals())
if details and not user_id:
result_query = query.executeOne()
result['hostip'] = result_query['hostip']
result['id'] = result_query['id']
else:
result = query.execute()
return result
def showOpts(self, as_string=True):
"""Returns hub options

View file

@ -2644,13 +2644,13 @@ class ClientSession(object):
self.authtype = AUTHTYPES['SSL']
return True
def logout(self):
def logout(self, session_id=None):
if not self.logged_in:
return
try:
# bypass _callMethod (no retries)
# XXX - is that really what we want?
handler, headers, request = self._prepCall('logout', ())
handler, headers, request = self._prepCall('logout', (), {"session_id": session_id})
self._sendCall(handler, headers, request)
except AuthExpired:
# this can happen when an exclusive session is forced

View file

@ -443,17 +443,29 @@ class Session(object):
update.execute()
context.cnx.commit()
def logout(self):
def logout(self, session_id=None):
"""expire a login session"""
if not self.logged_in:
# XXX raise an error?
raise koji.AuthError("Not logged in")
if session_id:
if not context.session.hasPerm('admin'):
query = QueryProcessor(tables=['sessions'], columns=['id'],
clauses=['user_id = %(user_id)i', 'id = %(session_id)s'],
values={'user_id': self.user_id, 'session_id': session_id})
if not query.singleValue():
raise koji.ActionNotAllowed('only admins or owner may logout other session')
ses_id = session_id
else:
ses_id = self.id
update = UpdateProcessor('sessions', data={'expired': True, 'exclusive': None},
clauses=['id = %(id)i OR master = %(id)i'],
values={'id': self.id})
values={'id': ses_id})
update.execute()
context.cnx.commit()
self.logged_in = False
if not session_id:
self.logged_in = False
def logoutChild(self, session_id):
"""expire a subsession"""
@ -749,9 +761,9 @@ def sslLogin(*args, **opts):
return context.session.sslLogin(*args, **opts)
def logout():
def logout(session_id=None):
"""expire a login session"""
return context.session.logout()
return context.session.logout(session_id)
def subsession():

View file

@ -0,0 +1,107 @@
import mock
import unittest
import koji
import kojihub
QP = kojihub.QueryProcessor
class TestGetSessionInfo(unittest.TestCase):
def getQuery(self, *args, **kwargs):
query = QP(*args, **kwargs)
query.execute = mock.MagicMock()
self.queries.append(query)
return query
def setUp(self):
self.context = mock.patch('kojihub.context').start()
self.exports = kojihub.RootExports()
self.QueryProcessor = mock.patch('kojihub.QueryProcessor',
side_effect=self.getQuery).start()
self.queries = []
self.context.session.hasPerm = mock.MagicMock()
self.get_user = mock.patch('kojihub.get_user').start()
self.userinfo = {'id': 123, 'name': 'testuser'}
self.exports.getLoggedInUser = mock.MagicMock()
def tearDown(self):
mock.patch.stopall()
def test_get_session_info_not_logged(self):
self.context.session.logged_in = False
result = self.exports.getSessionInfo()
self.assertIsNone(result)
def test_get_session_info_user_not_admin_and_not_logged_user(self):
self.context.session.logged_in = True
self.context.session.hasPerm.return_value = False
self.get_user.return_value = self.userinfo
self.exports.getLoggedInUser.return_value = {'id': 159, 'name': 'testuser2'}
with self.assertRaises(koji.ActionNotAllowed) as ex:
self.exports.getSessionInfo(user_id='testuser')
self.assertEqual("only admins or owners may see all active sessions", str(ex.exception))
self.assertEqual(len(self.queries), 0)
def test_get_session_info_user_logged_user(self):
self.context.session.logged_in = True
self.context.session.hasPerm.return_value = False
self.get_user.return_value = self.userinfo
self.exports.getLoggedInUser.return_value = {'id': 123, 'name': 'testuser'}
self.exports.getSessionInfo(user_id='testuser')
self.assertEqual(len(self.queries), 1)
query = self.queries[0]
self.assertEqual(query.tables, ['sessions'])
self.assertEqual(query.clauses, ['expired is FALSE', 'user_id = %(user_id)i'])
self.assertEqual(query.joins, None)
self.assertEqual(query.columns, ['authtype', 'callnum', 'exclusive', 'expired', 'master',
"date_part('epoch', start_time)", 'update_time',
'user_id'])
self.assertEqual(query.aliases, ['authtype', 'callnum', 'exclusive', 'expired', 'master',
'start_time', 'update_time', 'user_id'])
def test_get_session_info_user_and_details(self):
self.context.session.logged_in = True
self.context.session.hasPerm.return_value = True
self.exports.getSessionInfo(details=True, user_id='testuser')
self.assertEqual(len(self.queries), 1)
query = self.queries[0]
self.assertEqual(query.tables, ['sessions'])
self.assertEqual(query.clauses, ['expired is FALSE', 'user_id = %(user_id)i'])
self.assertEqual(query.joins, None)
self.assertEqual(query.columns, ['authtype', 'callnum', 'exclusive', 'expired', 'hostip',
'id', 'master', "date_part('epoch', start_time)",
'update_time', 'user_id'])
self.assertEqual(query.aliases, ['authtype', 'callnum', 'exclusive', 'expired', 'hostip',
'id', 'master', 'start_time', 'update_time', 'user_id'])
def test_get_session_info_user(self):
self.context.session.logged_in = True
self.context.session.hasPerm.return_value = True
self.exports.getSessionInfo(user_id='testuser')
self.assertEqual(len(self.queries), 1)
query = self.queries[0]
self.assertEqual(query.tables, ['sessions'])
self.assertEqual(query.clauses, ['expired is FALSE', 'user_id = %(user_id)i'])
self.assertEqual(query.joins, None)
self.assertEqual(query.columns, ['authtype', 'callnum', 'exclusive', 'expired', 'master',
"date_part('epoch', start_time)", 'update_time',
'user_id'])
self.assertEqual(query.aliases, ['authtype', 'callnum', 'exclusive', 'expired', 'master',
'start_time', 'update_time', 'user_id'])
def test_get_session_info_details(self):
self.context.session.logged_in = True
self.context.session.hasPerm.return_value = True
self.exports.getSessionInfo(details=True)
self.assertEqual(len(self.queries), 1)
query = self.queries[0]
self.assertEqual(query.tables, ['sessions'])
self.assertEqual(query.clauses, ['expired is FALSE', 'id = %(id)i'])
self.assertEqual(query.joins, None)
self.assertEqual(query.columns, ['authtype', 'callnum', 'exclusive', 'expired', 'hostip',
'id', 'master', "date_part('epoch', start_time)",
'update_time', 'user_id'])
self.assertEqual(query.aliases, ['authtype', 'callnum', 'exclusive', 'expired', 'hostip',
'id', 'master', 'start_time', 'update_time', 'user_id'])

View file

@ -624,3 +624,25 @@ class TestAuthSession(unittest.TestCase):
self.assertEqual(query.joins, ['permissions ON perm_id = permissions.id'])
self.assertEqual(query.clauses, ['active = TRUE', 'user_id=%(user_id)s'])
self.assertEqual(query.columns, ['name'])
def test_logout_not_logged(self):
s, cntext = self.get_session()
# not logged
s.logged_in = False
with self.assertRaises(koji.AuthError) as ex:
s.logout()
self.assertEqual("Not logged in", str(ex.exception))
@mock.patch('koji.auth.context')
def test_logout_logged_not_owner(self, context):
s, cntext = self.get_session()
s.logged_in = True
# session_id without admin perms and not owner
context.session.hasPerm.return_value = False
context.session.user_id.return_value = 123
self.query_singleValue.return_value = None
with self.assertRaises(koji.ActionNotAllowed) as ex:
s.logout(session_id=1)
self.assertEqual("only admins or owner may logout other session", str(ex.exception))

View file

@ -0,0 +1,28 @@
#include "includes/header.chtml"
#import koji
#from kojiweb import util
#attr _PASSTHROUGH = ['userID']
<h4>Active sessions for $loggedInUser.name user</h4>
<br>
<table class="data-list">
<tr class="list-header">
<th><a href="activesession?order=$util.toggleOrder($self, 'id')$util.passthrough_except($self, 'order')">Session ID</a> $util.sortImage($self, 'id')</th>
<th><a href="activesession?order=$util.toggleOrder($self, 'hostip')$util.passthrough_except($self, 'order')">Client IP</a> $util.sortImage($self, 'hostip')</th>
<th><a href="activesession?order=$util.toggleOrder($self, 'authtype')$util.passthrough_except($self, 'order')">Auth type</a> $util.sortImage($self, 'authtype')</th>
<th><a href="activesession?order=$util.toggleOrder($self, 'start_time')$util.passthrough_except($self, 'order')">Session start time</a> $util.sortImage($self, 'start_time')</th>
<th><a href="activesession?order=$util.toggleOrder($self, 'start_time')$util.passthrough_except($self, 'order')">Length session</a> $util.sortImage($self, 'start_time')</th>
<th><a href="activesession?order=$util.toggleOrder($self, 'id')$util.passthrough_except($self, 'order')">Logout?</a> $util.sortImage($self, 'id')</th>
</tr>
#for $act in $activesess
<tr class="$util.rowToggle($self)">
<td>$act.id</td>
<td>$util.escapeHTML($act.hostip)</td>
<td>$act.authtype</td>
<td>$util.formatTimeLong($act.start_time)</td>
<td>$act.lengthSession days</td>
<td><a href="activesessiondelete?sessionID=$act.id$util.authToken($self)">Logout</a></td>
</tr>
#end for
</table>

View file

@ -2135,7 +2135,11 @@ def buildtargetdelete(environ, targetID):
def reports(environ):
_getServer(environ)
_initValues(environ, 'Reports', 'reports')
values = _initValues(environ, 'Reports', 'reports')
if environ['koji.currentUser']:
values['loggedInUser'] = True
else:
values['loggedInUser'] = False
return _genHTML(environ, 'reports.chtml')
@ -2656,3 +2660,35 @@ def repoinfo(environ, repoID):
values['repo_json'] = os.path.join(
pathinfo.repo(repo_info['id'], repo_info['tag_name']), 'repo.json')
return _genHTML(environ, 'repoinfo.chtml')
def activesession(environ, start=None, order=None):
values = _initValues(environ, 'Active sessions', 'activesession')
server = _getServer(environ)
values['loggedInUser'] = environ['koji.currentUser']
values['order'] = order
activesess = server.getSessionInfo(details=True, user_id=values['loggedInUser']['id'])
if not activesess:
activesess = []
else:
current_timestamp = datetime.datetime.utcnow().timestamp()
for a in activesess:
a['lengthSession'] = kojiweb.util.formatTimestampDifference(
a['start_time'], current_timestamp, in_days=True)
kojiweb.util.paginateList(values, activesess, start, 'activesess', order=order)
return _genHTML(environ, 'activesession.chtml')
def activesessiondelete(environ, sessionID):
server = _getServer(environ)
_assertLogin(environ)
sessionID = int(sessionID)
server.logout(session_id=sessionID)
_redirect(environ, 'activesession')

View file

@ -13,6 +13,9 @@
<li><a href="buildsbystatus">Succeeded/failed/canceled builds</a></li>
<li><a href="buildsbytarget">Number of builds in each target</a></li>
<li><a href="clusterhealth">Cluster health</a></li>
#if $loggedInUser
<li><a href="activesession">Active sessions</a></li>
#end if
</ul>
#include "includes/footer.chtml"

View file

@ -469,13 +469,15 @@ formatTimeRSS = koji.formatTimeLong
formatTimeLong = koji.formatTimeLong
def formatTimestampDifference(start_ts, end_ts):
def formatTimestampDifference(start_ts, end_ts, in_days=False):
diff = end_ts - start_ts
seconds = diff % 60
diff = diff // 60
minutes = diff % 60
diff = diff // 60
hours = diff
if in_days:
return round(hours / 24, 1)
return "%d:%02d:%02d" % (hours, minutes, seconds)