Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/onegov/org/templates/user.pt
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@
<dt i18n:translate>Created</dt>
<dd>${layout.format_date(layout.model.created, 'datetime_long')}</dd>
<dt i18n:translate>Last login</dt>
<dd tal:condition="layout.model.modified">${layout.format_date(layout.model.modified, 'datetime_long')}</dd>
<dd tal:condition="not layout.model.modified" i18n:translate>Never</dd>
<dd tal:condition="layout.model.last_login">${layout.format_date(layout.model.last_login, 'datetime_long')}</dd>
<dd tal:condition="not layout.model.last_login" i18n:translate>Never</dd>
<tal:b define="sessions layout.model.data.get('sessions', '')">
<dt tal:condition="sessions" i18n:translate>Session information</dt>
<dd tal:condition="sessions">
Expand Down
4 changes: 2 additions & 2 deletions src/onegov/town6/templates/user.pt
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@
<dt i18n:translate>Created</dt>
<dd>${layout.format_date(layout.model.created, 'datetime_long')}</dd>
<dt i18n:translate>Last login</dt>
<dd tal:condition="layout.model.modified">${layout.format_date(layout.model.modified, 'datetime_long')}</dd>
<dd tal:condition="not layout.model.modified" i18n:translate>Never</dd>
<dd tal:condition="layout.model.last_login">${layout.format_date(layout.model.last_login, 'datetime_long')}</dd>
<dd tal:condition="not layout.model.last_login" i18n:translate>Never</dd>
<tal:b define="sessions layout.model.data.get('sessions', '')">
<dt tal:condition="sessions" i18n:translate>Session information</dt>
<dd tal:condition="sessions">
Expand Down
1 change: 1 addition & 0 deletions src/onegov/user/auth/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
8 changes: 7 additions & 1 deletion src/onegov/user/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 36 additions & 1 deletion src/onegov/user/upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
)
Comment thread
Daverball marked this conversation as resolved.

# 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;
"""
)
63 changes: 63 additions & 0 deletions tests/onegov/user/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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