diff --git a/src/onegov/org/templates/user.pt b/src/onegov/org/templates/user.pt
index c94d5e76e0..f371be20aa 100644
--- a/src/onegov/org/templates/user.pt
+++ b/src/onegov/org/templates/user.pt
@@ -46,8 +46,8 @@
Created
${layout.format_date(layout.model.created, 'datetime_long')}
Last login
- ${layout.format_date(layout.model.modified, 'datetime_long')}
- Never
+ ${layout.format_date(layout.model.last_login, 'datetime_long')}
+ Never
Session information
diff --git a/src/onegov/town6/templates/user.pt b/src/onegov/town6/templates/user.pt
index ebb5641521..1acde7a0f7 100644
--- a/src/onegov/town6/templates/user.pt
+++ b/src/onegov/town6/templates/user.pt
@@ -51,8 +51,8 @@
Created
${layout.format_date(layout.model.created, 'datetime_long')}
Last login
- ${layout.format_date(layout.model.modified, 'datetime_long')}
- Never
+ ${layout.format_date(layout.model.last_login, 'datetime_long')}
+ Never
Session information
diff --git a/src/onegov/user/auth/core.py b/src/onegov/user/auth/core.py
index 8a4c84079e..9806a320f0 100644
--- a/src/onegov/user/auth/core.py
+++ b/src/onegov/user/auth/core.py
@@ -369,6 +369,7 @@ def complete_login(
if hasattr(request.app, 'on_login'):
request.app.on_login(request, user)
+ user.last_login = utcnow()
user.save_current_session(request)
response.completed_login = True # type:ignore[attr-defined]
diff --git a/src/onegov/user/models/user.py b/src/onegov/user/models/user.py
index a2f3505983..2e159af8d8 100644
--- a/src/onegov/user/models/user.py
+++ b/src/onegov/user/models/user.py
@@ -3,7 +3,7 @@
from onegov.core.crypto import hash_password, verify_password
from onegov.core.orm import Base
from onegov.core.orm.mixins import data_property, dict_property, TimestampMixin
-from onegov.core.orm.types import JSON, UUID, LowercaseText
+from onegov.core.orm.types import JSON, UUID, LowercaseText, UTCDateTime
from onegov.core.security import forget, remembered
from onegov.core.utils import is_valid_yubikey_format
from onegov.core.utils import remove_repeated_dots
@@ -23,6 +23,7 @@
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
+ from datetime import datetime
from onegov.core.framework import Framework
from onegov.core.request import CoreRequest
from onegov.core.types import AppenderQuery
@@ -157,6 +158,11 @@ def userprofile(self) -> list[str]:
#: true if the user is active
active: Column[bool] = Column(Boolean, nullable=False, default=True)
+ #: timestamp of the last successful login
+ last_login: Column[datetime | None] = Column(
+ UTCDateTime, nullable=True, default=None
+ )
+
#: the signup token used by the user
signup_token: Column[str | None] = Column(
Text,
diff --git a/src/onegov/user/upgrade.py b/src/onegov/user/upgrade.py
index 4fe6fa6520..6ac4c8d161 100644
--- a/src/onegov/user/upgrade.py
+++ b/src/onegov/user/upgrade.py
@@ -6,7 +6,7 @@
from collections import defaultdict
from onegov.core.upgrade import upgrade_task
-from onegov.core.orm.types import JSON, UUID
+from onegov.core.orm.types import JSON, UUID, UTCDateTime
from onegov.user import User, UserCollection
from sqlalchemy import Boolean, Column, Text
from sqlalchemy.sql import text
@@ -301,3 +301,38 @@ def move_group_id_to_association_table(context: UpgradeContext) -> None:
context.session.flush()
context.operations.drop_column('users', 'group_id')
+
+
+@upgrade_task('Add last_login column')
+def add_last_login_column(context: UpgradeContext) -> None:
+ if not context.has_table('users'):
+ return
+
+ if not context.has_column('users', 'last_login'):
+ context.operations.add_column(
+ 'users', Column('last_login', UTCDateTime, nullable=True)
+ )
+
+ # Pre-populate last_login from existing session data
+ context.operations.execute(
+ """
+ UPDATE users
+ SET last_login = subquery.max_timestamp::timestamp
+ FROM (
+ SELECT
+ id,
+ MAX(
+ (session_value->>'timestamp')::timestamp
+ ) as max_timestamp
+ FROM
+ users,
+ LATERAL jsonb_each(data->'sessions')
+ AS session_entries(session_key, session_value)
+ WHERE
+ data->'sessions' IS NOT NULL
+ AND jsonb_typeof(data->'sessions') = 'object'
+ GROUP BY id
+ ) AS subquery
+ WHERE users.id = subquery.id;
+ """
+ )
diff --git a/tests/onegov/user/test_auth.py b/tests/onegov/user/test_auth.py
index 942f2c56ae..32d74fb355 100644
--- a/tests/onegov/user/test_auth.py
+++ b/tests/onegov/user/test_auth.py
@@ -330,3 +330,66 @@ def test_signup_expired(session: Session) -> None:
client_addr='127.0.0.1'
)
)
+
+
+def test_last_login_timestamp(session: Session, redis_url: str) -> None:
+
+ class App(Framework, UserApp):
+ pass
+
+ @App.identity_policy()
+ def get_identity_policy() -> IdentityPolicy:
+ return IdentityPolicy()
+
+ @App.path(path='/auth', model=Auth)
+ def get_auth() -> Auth:
+ return Auth(DummyApp(session), to='/') # type: ignore[arg-type]
+
+ @App.view(model=Auth)
+ def view_auth(self: Auth, request: CoreRequest) -> Response | str:
+ return (
+ self.login_to(
+ request.GET['username'], request.GET['password'], request
+ )
+ or 'Error'
+ )
+
+ App.commit()
+
+ UserCollection(session).add('testuser', 'testpass', 'member')
+ transaction.commit()
+
+ app = App()
+ app.namespace = 'test'
+ app.configure_application(identity_secure=False, redis_url=redis_url)
+ app.application_id = 'test/last-login'
+
+ client = Client(app)
+
+ user = UserCollection(session).by_username('testuser')
+ assert user is not None
+ assert user.last_login is None
+
+ before_login = utcnow()
+ response = client.get('/auth?username=testuser&password=testpass')
+ after_login = utcnow()
+
+ assert response.status_code == 302
+
+ session.expire_all()
+ user = UserCollection(session).by_username('testuser')
+ assert user is not None
+ assert user.last_login is not None
+ assert before_login <= user.last_login <= after_login
+
+ first_login = user.last_login
+ time.sleep(0.1)
+
+ response = client.get('/auth?username=testuser&password=testpass')
+ assert response.status_code == 302
+
+ session.expire_all()
+ user = UserCollection(session).by_username('testuser')
+ assert user is not None
+ assert user.last_login is not None
+ assert user.last_login > first_login