From 86d928203efe0a7d55ac6607a5b4f2d769953e21 Mon Sep 17 00:00:00 2001
From: Jana Cupova <root@localhost>
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']
+
+<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>
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 @@
     <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"
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)
 
 

