# SPDX-FileCopyrightText: (C) 2026 NetKnights GmbH <https://netknights.it>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# This code is free software; you can redistribute it and/or
# modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
# as published by the Free Software Foundation; either
# version 3 of the License, or any later version.
#
# This code is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU AFFERO GENERAL PUBLIC LICENSE for more details.
#
# You should have received a copy of the GNU Affero General Public
# License along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
from datetime import datetime, timedelta
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column
from privacyidea.lib.log import log_with
from privacyidea.models import db
from privacyidea.models.utils import MethodsMixin, utc_now
log = logging.getLogger(__name__)
# Default lifetime of a remembered device.
DEFAULT_DEVICE_VALIDITY = timedelta(days=30)
[docs]
class RememberedDevice(MethodsMixin, db.Model):
"""
A persistent ("remember this device") authentication device bound to a
specific API client.
The device implements a rotating-token scheme: the client stores a cookie
of the form ``series_id:counter``. On every use the counter is incremented
both in the cookie and in this row. If a request presents the correct
``series_id`` but a stale ``counter``, the token has been replayed (the
cookie was stolen), the whole series is deleted and authentication fails.
The cookie never contains the API key; the binding to the client is stored
server-side in ``client_id`` and checked against ``g.client_id``.
The remembered user is bound by the resolver-stable identity
``(resolver, user_id, realm_id)`` - **not** by the login name. The login is a
mutable, reusable label in an external user store: binding to it would drop
the remembered device on a rename and, worse, could recognise a *different*
account that later reuses a freed login. ``user_id`` is therefore the
resolver's immutable id (as in :class:`TokenOwner`), and ``realm_id`` is a
foreign key so a deleted realm cascades its devices away.
The composite ``(client_id, resolver, user_id, realm_id)`` index serves the
two lookups done on every remember-device request: :func:`get_valid_device`
(cookie validation) and :func:`count_user_devices` (the per-user device cap
at issuance), both of which filter on all four columns at once. At
``utf8mb4`` this composite is 1908 bytes, under MySQL's 3072-byte
index-key limit, so - unlike ``usersetting``'s ``subject_hash`` - no hashed
column is needed here. The individual single-column indexes stay: they are
still required by :func:`revoke_devices`, the client-independent bulk
revoke that filters by realm/user without a ``client_id``.
The composite ``(client_id, created_at)`` index serves the paginated,
newest-first listing in :func:`get_client_devices`: ``(client_id, resolver,
user_id, realm_id)`` above cannot help that query order rows by
``created_at``, so without this a client with a very large number of
devices would need every one of its rows collected and sorted before a
single page could be returned.
"""
__tablename__ = 'remembered_devices'
__table_args__ = (
Index('ix_remembered_devices_identity', 'client_id', 'resolver', 'user_id', 'realm_id'),
Index('ix_remembered_devices_client_created', 'client_id', 'created_at'),
)
# series_id is the secret half of the cookie (series_id:counter) and is never
# exposed in an API response, URL or log. device_id is a separate, non-secret
# handle used to list and revoke a device, so managing a device never leaks
# reusable credential material.
series_id: Mapped[str] = mapped_column(Unicode(64), primary_key=True)
device_id: Mapped[str] = mapped_column(Unicode(64), unique=True, nullable=False)
counter: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
client_id: Mapped[str] = mapped_column(Unicode(36), ForeignKey("clients.id", ondelete="CASCADE"),
index=True, nullable=False)
resolver: Mapped[str] = mapped_column(Unicode(120), index=True, nullable=False)
user_id: Mapped[str] = mapped_column(Unicode(320), index=True, nullable=False)
realm_id: Mapped[int] = mapped_column(Integer, ForeignKey("realm.id", ondelete="CASCADE"),
index=True, nullable=False)
ip_address: Mapped[str | None] = mapped_column(Unicode(64))
user_agent: Mapped[str | None] = mapped_column(Unicode(255))
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
expires_at: Mapped[datetime] = mapped_column(DateTime, index=True, nullable=False)
@log_with(log)
def __init__(self, series_id, device_id, client_id, resolver, user_id, realm_id,
ip_address=None, user_agent=None, counter=1, expires_at=None):
self.series_id = series_id
self.device_id = device_id
self.counter = counter
self.client_id = client_id
self.resolver = resolver
self.user_id = user_id
self.realm_id = realm_id
self.ip_address = ip_address
self.user_agent = user_agent
self.expires_at = expires_at if expires_at is not None else utc_now() + DEFAULT_DEVICE_VALIDITY