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