/
timed.py
234 lines (195 loc) · 8.01 KB
/
timed.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
import time
import typing
import typing as _t
from datetime import datetime
from datetime import timezone
from .encoding import base64_decode
from .encoding import base64_encode
from .encoding import bytes_to_int
from .encoding import int_to_bytes
from .encoding import want_bytes
from .exc import BadSignature
from .exc import BadTimeSignature
from .exc import SignatureExpired
from .serializer import Serializer
from .signer import Signer
_t_str_bytes = _t.Union[str, bytes]
_t_opt_str_bytes = _t.Optional[_t_str_bytes]
_t_opt_int = _t.Optional[int]
if _t.TYPE_CHECKING:
import typing_extensions as _te
class TimestampSigner(Signer):
"""Works like the regular :class:`.Signer` but also records the time
of the signing and can be used to expire signatures. The
:meth:`unsign` method can raise :exc:`.SignatureExpired` if the
unsigning failed because the signature is expired.
"""
def get_timestamp(self) -> int:
"""Returns the current timestamp. The function must return an
integer.
"""
return int(time.time())
def timestamp_to_datetime(self, ts: int) -> datetime:
"""Convert the timestamp from :meth:`get_timestamp` into an
aware :class`datetime.datetime` in UTC. Raises :exc:`.ValueError`
if the timestamp is too far in the past or future for Python.
.. versionchanged:: 2.0
The timestamp is returned as a timezone-aware ``datetime``
in UTC rather than a naive ``datetime`` assumed to be UTC.
"""
return datetime.fromtimestamp(ts, tz=timezone.utc)
def sign(self, value: _t_str_bytes) -> bytes:
"""Signs the given string and also attaches time information."""
value = want_bytes(value)
timestamp = base64_encode(int_to_bytes(self.get_timestamp()))
sep = want_bytes(self.sep)
value = value + sep + timestamp
return value + sep + self.get_signature(value)
# Ignore overlapping signatures check, return_timestamp is the only
# parameter that affects the return type.
@typing.overload
def unsign( # type: ignore
self,
signed_value: _t_str_bytes,
max_age: _t_opt_int = None,
return_timestamp: "_te.Literal[False]" = False,
) -> bytes:
...
@typing.overload
def unsign(
self,
signed_value: _t_str_bytes,
max_age: _t_opt_int = None,
return_timestamp: "_te.Literal[True]" = True,
) -> _t.Tuple[bytes, datetime]:
...
def unsign(
self,
signed_value: _t_str_bytes,
max_age: _t_opt_int = None,
return_timestamp: bool = False,
) -> _t.Union[_t.Tuple[bytes, datetime], bytes]:
"""Works like the regular :meth:`.Signer.unsign` but can also
validate the time. See the base docstring of the class for
the general behavior. If ``return_timestamp`` is ``True`` the
timestamp of the signature will be returned as an aware
:class:`datetime.datetime` object in UTC.
.. versionchanged:: 2.0
The timestamp is returned as a timezone-aware ``datetime``
in UTC rather than a naive ``datetime`` assumed to be UTC.
"""
try:
result = super().unsign(signed_value)
sig_error = None
except BadSignature as e:
sig_error = e
result = e.payload or b""
sep = want_bytes(self.sep)
# If there is no timestamp in the result there is something
# seriously wrong. In case there was a signature error, we raise
# that one directly, otherwise we have a weird situation in
# which we shouldn't have come except someone uses a time-based
# serializer on non-timestamp data, so catch that.
if sep not in result:
if sig_error:
raise sig_error
raise BadTimeSignature("timestamp missing", payload=result)
value, ts_bytes = result.rsplit(sep, 1)
ts_int: _t_opt_int = None
ts_dt: _t.Optional[datetime] = None
try:
ts_int = bytes_to_int(base64_decode(ts_bytes))
except Exception:
pass
# Signature is *not* okay. Raise a proper error now that we have
# split the value and the timestamp.
if sig_error is not None:
if ts_int is not None:
try:
ts_dt = self.timestamp_to_datetime(ts_int)
except (ValueError, OSError) as exc:
# Windows raises OSError
raise BadTimeSignature(
"Malformed timestamp", payload=value
) from exc
raise BadTimeSignature(str(sig_error), payload=value, date_signed=ts_dt)
# Signature was okay but the timestamp is actually not there or
# malformed. Should not happen, but we handle it anyway.
if ts_int is None:
raise BadTimeSignature("Malformed timestamp", payload=value)
# Check timestamp is not older than max_age
if max_age is not None:
age = self.get_timestamp() - ts_int
if age > max_age:
raise SignatureExpired(
f"Signature age {age} > {max_age} seconds",
payload=value,
date_signed=self.timestamp_to_datetime(ts_int),
)
if age < 0:
raise SignatureExpired(
f"Signature age {age} < 0 seconds",
payload=value,
date_signed=self.timestamp_to_datetime(ts_int),
)
if return_timestamp:
return value, self.timestamp_to_datetime(ts_int)
return value
def validate(self, signed_value: _t_str_bytes, max_age: _t_opt_int = None) -> bool:
"""Only validates the given signed value. Returns ``True`` if
the signature exists and is valid."""
try:
self.unsign(signed_value, max_age=max_age)
return True
except BadSignature:
return False
class TimedSerializer(Serializer):
"""Uses :class:`TimestampSigner` instead of the default
:class:`.Signer`.
"""
default_signer: _t.Type[TimestampSigner] = TimestampSigner
def iter_unsigners(
self, salt: _t_opt_str_bytes = None
) -> _t.Iterator[TimestampSigner]:
return _t.cast("_t.Iterator[TimestampSigner]", super().iter_unsigners(salt))
# TODO: Signature is incompatible because parameters were added
# before salt.
def loads( # type: ignore
self,
s: _t_str_bytes,
max_age: _t_opt_int = None,
return_timestamp: bool = False,
salt: _t_opt_str_bytes = None,
) -> _t.Any:
"""Reverse of :meth:`dumps`, raises :exc:`.BadSignature` if the
signature validation fails. If a ``max_age`` is provided it will
ensure the signature is not older than that time in seconds. In
case the signature is outdated, :exc:`.SignatureExpired` is
raised. All arguments are forwarded to the signer's
:meth:`~TimestampSigner.unsign` method.
"""
s = want_bytes(s)
last_exception = None
for signer in self.iter_unsigners(salt):
try:
base64d, timestamp = signer.unsign(
s, max_age=max_age, return_timestamp=True
)
payload = self.load_payload(base64d)
if return_timestamp:
return payload, timestamp
return payload
except SignatureExpired:
# The signature was unsigned successfully but was
# expired. Do not try the next signer.
raise
except BadSignature as err:
last_exception = err
raise _t.cast(BadSignature, last_exception)
def loads_unsafe( # type: ignore
self,
s: _t_str_bytes,
max_age: _t_opt_int = None,
salt: _t_opt_str_bytes = None,
) -> _t.Tuple[bool, _t.Any]:
return self._loads_unsafe_impl(s, salt, load_kwargs={"max_age": max_age})