v0.166.0
  1import time
  2import zlib
  3from typing import Any
  4
  5from plain.signing import (
  6    JSONSerializer,
  7    SignatureExpired,
  8    Signer,
  9    b62_decode,
 10    b62_encode,
 11    b64_decode,
 12    b64_encode,
 13)
 14
 15
 16class ExpiringSigner:
 17    """A signer with an embedded expiration (vs max age unsign).
 18
 19    Uses composition rather than inheritance since the interface
 20    intentionally differs from Signer (requires expires_in parameter).
 21    """
 22
 23    def __init__(
 24        self,
 25        *,
 26        key: str | None = None,
 27        sep: str = ":",
 28        salt: str | None = None,
 29        algorithm: str = "sha256",
 30        fallback_keys: list[str] | None = None,
 31    ) -> None:
 32        # Compute default salt here to preserve backwards compatibility.
 33        # When ExpiringSigner inherited from Signer, the default salt was
 34        # "plain.loginlink.signing.ExpiringSigner". Now that we use composition,
 35        # we must set it explicitly rather than letting Signer compute its own.
 36        if salt is None:
 37            salt = f"{self.__class__.__module__}.{self.__class__.__name__}"
 38        self._signer = Signer(
 39            key=key,
 40            sep=sep,
 41            salt=salt,
 42            algorithm=algorithm,
 43            fallback_keys=fallback_keys,
 44        )
 45
 46    @property
 47    def sep(self) -> str:
 48        return self._signer.sep
 49
 50    def sign(self, value: str, expires_in: int) -> str:
 51        timestamp = b62_encode(int(time.time() + expires_in))
 52        value = f"{value}{self.sep}{timestamp}"
 53        return self._signer.sign(value)
 54
 55    def unsign(self, signed_value: str) -> str:
 56        """
 57        Retrieve original value and check the expiration hasn't passed.
 58        """
 59        result = self._signer.unsign(signed_value)
 60        value, timestamp = result.rsplit(self.sep, 1)
 61        ts = b62_decode(timestamp)
 62        if ts < time.time():
 63            raise SignatureExpired("Signature expired")
 64        return value
 65
 66    def sign_object(
 67        self,
 68        obj: Any,
 69        *,
 70        expires_in: int,
 71        serializer: type = JSONSerializer,
 72        compress: bool = False,
 73    ) -> str:
 74        """
 75        Return URL-safe, hmac signed base64 compressed JSON string.
 76
 77        If compress is True (not the default), check if compressing using zlib
 78        can save some space. Prepend a '.' to signify compression. This is
 79        included in the signature, to protect against zip bombs.
 80
 81        The serializer is expected to return a bytestring.
 82        """
 83        data = serializer().dumps(obj)
 84        # Flag for if it's been compressed or not.
 85        is_compressed = False
 86
 87        if compress:
 88            # Avoid zlib dependency unless compress is being used.
 89            compressed = zlib.compress(data)
 90            if len(compressed) < (len(data) - 1):
 91                data = compressed
 92                is_compressed = True
 93        base64d = b64_encode(data).decode()
 94        if is_compressed:
 95            base64d = "." + base64d
 96        return self.sign(base64d, expires_in)
 97
 98    def unsign_object(self, signed_obj: str, serializer: type = JSONSerializer) -> Any:
 99        # Signer.unsign() returns str but base64 and zlib compression operate
100        # on bytes.
101        base64d = self.unsign(signed_obj).encode()
102        decompress = base64d[:1] == b"."
103        if decompress:
104            # It's compressed; uncompress it first.
105            base64d = base64d[1:]
106        data = b64_decode(base64d)
107        if decompress:
108            data = zlib.decompress(data)
109        return serializer().loads(data)