From 86d928203efe0a7d55ac6607a5b4f2d769953e21 Mon Sep 17 00:00:00 2001 From: Jana Cupova Date: Nov 09 2022 08:55:34 +0000 Subject: Add active sessions web page Fixes: https://pagure.io/koji/issue/3396 --- diff --git a/hub/kojihub.py b/hub/kojihub.py index 0899e66..680f9d3 100644 --- a/hub/kojihub.py +++ b/hub/kojihub.py @@ -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 diff --git a/koji/__init__.py b/koji/__init__.py index 3a02419..18cedcd 100644 --- a/koji/__init__.py +++ b/koji/__init__.py @@ -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 diff --git a/koji/auth.py b/koji/auth.py index d1595a5..9ab19f1 100644 --- a/koji/auth.py +++ b/koji/auth.py @@ -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(): diff --git a/tests/test_hub/test_get_session_info.py b/tests/test_hub/test_get_session_info.py new file mode 100644 index 0000000..6b21b42 --- /dev/null +++ b/tests/test_hub/test_get_session_info.py @@ -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']) diff --git a/tests/test_lib/test_auth.py b/tests/test_lib/test_auth.py index d94aea9..5b3f5fd 100644 --- a/tests/test_lib/test_auth.py +++ b/tests/test_lib/test_auth.py @@ -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)) diff --git a/www/kojiweb/activesession.chtml b/www/kojiweb/activesession.chtml new file mode 100644 index 0000000..df4ca30 --- /dev/null +++ b/www/kojiweb/activesession.chtml @@ -0,0 +1,28 @@ +#include "includes/header.chtml" +#import koji +#from kojiweb import util + +#attr _PASSTHROUGH = ['userID'] + +

Active sessions for $loggedInUser.name user

+
+ + + + + + + + + + #for $act in $activesess + + + + + + + + + #end for +
Session ID $util.sortImage($self, 'id')Client IP $util.sortImage($self, 'hostip')Auth type $util.sortImage($self, 'authtype')Session start time $util.sortImage($self, 'start_time')Length session $util.sortImage($self, 'start_time')Logout? $util.sortImage($self, 'id')
$act.id$util.escapeHTML($act.hostip)$act.authtype$util.formatTimeLong($act.start_time)$act.lengthSession daysLogout
diff --git a/www/kojiweb/index.py b/www/kojiweb/index.py index bdb3841..543873c 100644 --- a/www/kojiweb/index.py +++ b/www/kojiweb/index.py @@ -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') diff --git a/www/kojiweb/reports.chtml b/www/kojiweb/reports.chtml index 9b0b988..0c32042 100644 --- a/www/kojiweb/reports.chtml +++ b/www/kojiweb/reports.chtml @@ -13,6 +13,9 @@
  • Succeeded/failed/canceled builds
  • Number of builds in each target
  • Cluster health
  • + #if $loggedInUser +
  • Active sessions
  • + #end if #include "includes/footer.chtml" diff --git a/www/lib/kojiweb/util.py b/www/lib/kojiweb/util.py index a97fe3d..628a596 100644 --- a/www/lib/kojiweb/util.py +++ b/www/lib/kojiweb/util.py @@ -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)