Repository: ckan/ckan

Issue:
There is no way to find out when a user last logged in
**CKAN version**
2.9.1

**Describe the bug**
The `user` table stores the `created` timestamp, but not last login.  This information is needed to:
 - purge idle users (#5658)
 - enforce password aging policies
 - help track utilization
 - help implement brute force lockout? (perhaps, by adding another column - `last_login_attempt`?)

**Steps to reproduce**
Register a user.

**Expected behavior**
A `last_login` column is added to the `user` table, similar to the `last_accessed` column in the new `api_token` table.



Discussion:
This is a valuable suggestion.
I implemented this in the past but with a `last_active` field, which is more granular, hooking into [`identify_user()`](https://github.com/ckan/ckan/blob/04cc84540ffd7b00e70a04de6f84f1b0721ca838/ckan/views/__init__.py#L122). If we wanted actual logins we could hook it in [`logged_in`](https://github.com/ckan/ckan/blob/04cc84540ffd7b00e70a04de6f84f1b0721ca838/ckan/views/user.py#L468) but that would not take API access into account.
`last_active` is even better!  Will pull together a PR. 
@amercader , I am interested to work on this issue. As you said that: 

> If we wanted actual logins we could hook it in logged_in but that would not take API access into account.

What would be the correct approach to develop this feature?


Resolving patch:
diff --git a/ckan/migration/versions/101_d111f446733b_add_last_active_column_in_user_table.py b/ckan/migration/versions/101_d111f446733b_add_last_active_column_in_user_table.py
new file mode 100644
--- /dev/null
+++ b/ckan/migration/versions/101_d111f446733b_add_last_active_column_in_user_table.py
@@ -0,0 +1,26 @@
+# encoding: utf-8
+
+"""Add last_active column in user table
+
+Revision ID: d111f446733b
+Revises: ccd38ad5fced
+Create Date: 2021-09-22 00:44:40.777435
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'd111f446733b'
+down_revision = 'ccd38ad5fced'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    op.add_column('user', sa.Column('last_active', sa.TIMESTAMP))
+
+
+def downgrade():
+    op.remove_column('user', sa.Column('last_active'))
diff --git a/ckan/model/user.py b/ckan/model/user.py
--- a/ckan/model/user.py
+++ b/ckan/model/user.py
@@ -18,6 +18,14 @@
 from ckan.model import core
 from ckan.model import types as _types
 from ckan.model import domain_object
+from ckan.common import config, asbool, asint, session
+
+
+def last_active_check():
+    last_active = asint(config.get('ckan.user.last_active_interval', 600))
+    calc_time = datetime.datetime.utcnow() - datetime.timedelta(seconds=last_active)
+
+    return calc_time
 
 
 def set_api_key():
@@ -37,6 +45,7 @@ def set_api_key():
         Column('created', types.DateTime, default=datetime.datetime.now),
         Column('reset_key', types.UnicodeText),
         Column('about', types.UnicodeText),
+        Column('last_active', types.TIMESTAMP),
         Column('activity_streams_email_notifications', types.Boolean,
             default=False),
         Column('sysadmin', types.Boolean, default=False),
@@ -293,6 +302,16 @@ def user_ids_for_name_or_id(cls, user_list=[]):
                                  cls.id.in_(user_list)))
         return [user.id for user in query.all()]
 
+    def set_user_last_active(self):
+        if self.last_active:
+            session["last_active"] = self.last_active
+
+            if session["last_active"] < last_active_check():
+                self.last_active = datetime.datetime.utcnow()
+                meta.Session.commit()
+        else:
+            self.last_active = datetime.datetime.utcnow()
+            meta.Session.commit()
 
 meta.mapper(User, user_table,
     properties={'password': synonym('_password', map_column=True)},
diff --git a/ckan/views/__init__.py b/ckan/views/__init__.py
--- a/ckan/views/__init__.py
+++ b/ckan/views/__init__.py
@@ -141,6 +141,9 @@ def identify_user():
 
     # general settings
     if g.user:
+        if g.userobj:
+            userobj = g.userobj
+            userobj.set_user_last_active()
         g.author = g.user
     else:
         g.author = g.remote_addr


Test patch:
diff --git a/ckan/tests/model/test_user.py b/ckan/tests/model/test_user.py
--- a/ckan/tests/model/test_user.py
+++ b/ckan/tests/model/test_user.py
@@ -8,7 +8,6 @@
 
 from passlib.hash import pbkdf2_sha512
 
-
 import ckan.model as model
 import ckan.tests.factories as factories
 
@@ -163,6 +162,7 @@ def test_basic(self):
         assert len(user.apikey) == 36
         assert user.fullname == data["fullname"]
         assert user.email == data["email"]
+        assert user.last_active is None
 
     def test_get(self):
         factories.User(fullname="Brian", name="brian")
@@ -228,3 +228,30 @@ def test_number_of_administered_packages(self):
 
         user1.number_created_packages() == 1
         user2.number_created_packages() == 0
+
+    def test_user_last_active(self, app):
+        import datetime
+        from ckan.lib.helpers import url_for
+        from freezegun import freeze_time
+
+        frozen_time = datetime.datetime.utcnow()
+        data = factories.User()
+        dataset = factories.Dataset()
+        env = {"REMOTE_USER": str(data["name"])}
+
+        with freeze_time(frozen_time):
+            user = model.User.get(data["id"])
+            assert user.last_active is None
+            app.get(url_for("dataset.search"), extra_environ=env)
+            assert isinstance(user.last_active, datetime.datetime)
+            assert user.last_active == datetime.datetime.utcnow()
+
+        with freeze_time(frozen_time + datetime.timedelta(seconds=540)):
+            user = model.User.get(data["id"])
+            app.get(url_for("user.read", id=user.id), extra_environ=env)
+            assert user.last_active != datetime.datetime.utcnow()
+
+        with freeze_time(frozen_time + datetime.timedelta(seconds=660)):
+            user = model.User.get(data["id"])
+            app.get(url_for("dataset.read", id=dataset["id"]), extra_environ=env)
+            assert user.last_active == datetime.datetime.utcnow()