Source code for privacyidea.models.challenge

# SPDX-FileCopyrightText: (C) 2025 NetKnights GmbH <https://netknights.it>
# SPDX-FileCopyrightText: (C) 2025 Paul Lettich <paul.lettich@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 json
import logging
from datetime import datetime, timedelta

from sqlalchemy import (
    Sequence,
    Unicode,
    Integer,
    Text,
    DateTime,
    Boolean,
    delete,
)
from sqlalchemy.orm import Mapped, mapped_column

from privacyidea.lib.challenge_types import is_challenge_open
from privacyidea.lib.crypto import FAILED_TO_DECRYPT_PASSWORD, get_rand_digit_str, encryptPassword, decryptPassword
from privacyidea.lib.log import log_with
from privacyidea.lib.utils import convert_column_to_unicode
from privacyidea.models import db
from privacyidea.models.utils import MethodsMixin, utc_now

log = logging.getLogger(__name__)


[docs] class Challenge(MethodsMixin, db.Model): """ Table for handling of the generic challenges. """ __tablename__ = "challenge" id: Mapped[int] = mapped_column(Integer, Sequence("challenge_seq"), primary_key=True, nullable=False) transaction_id: Mapped[str] = mapped_column(Unicode(64), nullable=False, index=True) _data: Mapped[str | None] = mapped_column("data", Text, default='') challenge: Mapped[str | None] = mapped_column(Text, default='') session: Mapped[str | None] = mapped_column(Unicode(512), default='', quote=True, name="session") # The token serial number serial: Mapped[str | None] = mapped_column(Unicode(40), default='', index=True) timestamp: Mapped[datetime | None] = mapped_column(DateTime, default=utc_now, index=True) expiration: Mapped[datetime | None] = mapped_column(DateTime, index=True) received_count: Mapped[int | None] = mapped_column(Integer, default=0) otp_valid: Mapped[bool | None] = mapped_column(Boolean, default=False) @property def data(self): """Return the decrypted challenge data as a JSON string, or empty string.""" raw = self._data if not raw: return raw decrypted = decryptPassword(raw) if decrypted == FAILED_TO_DECRYPT_PASSWORD: log.warning("Challenge %s: failed to decrypt data.", self.transaction_id) return '' return decrypted @data.setter def data(self, value): """Allow direct assignment to data (encrypts before storing).""" self.set_data(value) @property def encrypted_data(self) -> str: """ Return the stored ciphertext of ``data``, without decrypting it. For a caller that has to persist the very same value somewhere else - the Redis cache stores this challenge instead of the row - so that it does not have to decrypt and re-encrypt what is already encrypted here. """ return self._data or '' @log_with(log) def __init__(self, serial, transaction_id=None, challenge='', data=None, session='', validitytime=120): # We manually assign attributes here as they depend on the function parameters self.transaction_id = transaction_id or self.create_transaction_id() self.challenge = challenge self.serial = serial self.set_data(data) self.timestamp = utc_now() self.session = session self.received_count = 0 self.otp_valid = False self.expiration = self.timestamp + timedelta(seconds=validitytime) @staticmethod def create_transaction_id(length=20): return get_rand_digit_str(length)
[docs] def is_valid(self): """ Returns true, if the expiration time has not passed, yet. :return: True if valid :rtype: bool """ c_now = utc_now() if self.timestamp <= c_now < self.expiration: return True return False
[docs] def is_open(self) -> bool: """ Returns true if the challenge can still be answered: it is not expired, has not been answered yet, and has not been refused (declined or cancelled). This is the guard the answering endpoints use before mutating a challenge. :return: True if the challenge is still open :rtype: bool """ return is_challenge_open(self.is_valid(), self.otp_valid, self.get_session())
[docs] def set_data(self, data: dict | None): """ Set the internal data of the challenge. The data is encrypted before being stored in the database since it may contain OTP values. :param data: The challenge data. Must be a dict (or None/empty). """ if not data: self._data = '' else: self._data = encryptPassword(json.dumps(data))
[docs] def get_data(self) -> dict: """ Get the decrypted challenge data as a dict. :return: The challenge data as a dict. Returns ``{}`` if no data is stored. :rtype: dict """ data = self.data if not data: return {} try: result = json.loads(data) except (json.JSONDecodeError, ValueError): log.warning("Challenge %s: failed to decode data as JSON.", self.transaction_id) return {} if not isinstance(result, dict): log.warning("Challenge %s: data is not a dict (got %s). Returning empty dict.", self.transaction_id, type(result).__name__) return {} return result
def get_session(self): return self.session def set_session(self, session): self.session = convert_column_to_unicode(session) def set_challenge(self, challenge): self.challenge = convert_column_to_unicode(challenge) def get_challenge(self): return self.challenge def set_otp_status(self, valid=False): self.received_count += 1 self.otp_valid = valid
[docs] def get_otp_status(self): """ This returns how many OTPs were already received for this challenge. and if a valid OTP was received. :return: tuple of count and True/False :rtype: tuple """ return self.received_count, self.otp_valid
def get_transaction_id(self): return self.transaction_id
[docs] def get(self, timestamp=False): """ return a dictionary of all vars in the challenge class :param timestamp: if true, the timestamp will be returned in a readable format like "2014-11-29 21:56:43.057293" :type timestamp: bool :return: dict of vars """ # The SQL primary key is intentionally not part of the public # challenge representation. transaction_id is the identity field # callers should use. .id is a storage-layer artifact, and exposing # it would let consumers diverge between the SQL backend (real int) # and the Redis cache backend (no PK at all - ChallengeDTO doesn't # expose .id). descr = { 'transaction_id': self.transaction_id, 'challenge': self.challenge, 'serial': self.serial, 'data': self.get_data(), 'otp_received': self.received_count > 0, 'received_count': self.received_count, 'otp_valid': self.otp_valid, 'expiration': self.expiration, } if timestamp is True: descr['timestamp'] = f"{self.timestamp}" else: descr['timestamp'] = self.timestamp return descr
def __str__(self): descr = self.get() return f"{descr!s}"
[docs] def cleanup_challenges(serial): """ Delete all challenges, that have expired. :return: None """ c_now = utc_now() # DB contains naive datetime # Replaced the legacy .query.delete() with a modern delete statement delete_stmt = delete(Challenge).where( Challenge.expiration < c_now, Challenge.serial == serial ) db.session.execute(delete_stmt) db.session.commit()