Source code for privacyidea.models.remembered_device

# 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