Plain
Plain is a web framework for building products with Python.
With the core plain
package you can build an app that:
- Matches URL patterns to Python views
- Handles HTTP requests and responses
- Renders HTML templates with Jinja
- Processes user input via forms
- Has a CLI interface
- Serves static assets (CSS, JS, images)
- Can be modified with middleware
- Integrates first-party and third-party packages
- Has a preflight check system
With the official Plain ecosystem packages you can:
- Integrate a full-featured database ORM
- Use a built-in user authentication system
- Lint and format code
- Run a database-backed cache
- Send emails
- Streamline local development
- Manage feature flags
- Integrate HTMX
- Style with Tailwind CSS
- Add OAuth login and API access
- Run tests with pytest
- Run a background job worker
- Build staff tooling and admin dashboards
Learn more at plainframework.com.
1"""
2Functions for creating and restoring url-safe signed JSON objects.
3
4The format used looks like this:
5
6>>> signing.dumps("hello")
7'ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk'
8
9There are two components here, separated by a ':'. The first component is a
10URLsafe base64 encoded JSON of the object passed to dumps(). The second
11component is a base64 encoded hmac/SHA-256 hash of "$first_component:$secret"
12
13signing.loads(s) checks the signature and returns the deserialized object.
14If the signature fails, a BadSignature exception is raised.
15
16>>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk")
17'hello'
18>>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv42-modified")
19...
20BadSignature: Signature "ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv42-modified" does not match
21
22You can optionally compress the JSON prior to base64 encoding it to save
23space, using the compress=True argument. This checks if compression actually
24helps and only applies compression if the result is a shorter string:
25
26>>> signing.dumps(list(range(1, 20)), compress=True)
27'.eJwFwcERACAIwLCF-rCiILN47r-GyZVJsNgkxaFxoDgxcOHGxMKD_T7vhAml:1QaUaL:BA0thEZrp4FQVXIXuOvYJtLJSrQ'
28
29The fact that the string is compressed is signalled by the prefixed '.' at the
30start of the base64 JSON.
31
32There are 65 url-safe characters: the 64 used by url-safe base64 and the ':'.
33These functions make use of all of them.
34"""
35
36import base64
37import datetime
38import json
39import time
40import zlib
41
42from plain.runtime import settings
43from plain.utils.crypto import constant_time_compare, salted_hmac
44from plain.utils.encoding import force_bytes
45from plain.utils.module_loading import import_string
46from plain.utils.regex_helper import _lazy_re_compile
47
48_SEP_UNSAFE = _lazy_re_compile(r"^[A-z0-9-_=]*$")
49BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
50
51
52class BadSignature(Exception):
53 """Signature does not match."""
54
55 pass
56
57
58class SignatureExpired(BadSignature):
59 """Signature timestamp is older than required max_age."""
60
61 pass
62
63
64def b62_encode(s):
65 if s == 0:
66 return "0"
67 sign = "-" if s < 0 else ""
68 s = abs(s)
69 encoded = ""
70 while s > 0:
71 s, remainder = divmod(s, 62)
72 encoded = BASE62_ALPHABET[remainder] + encoded
73 return sign + encoded
74
75
76def b62_decode(s):
77 if s == "0":
78 return 0
79 sign = 1
80 if s[0] == "-":
81 s = s[1:]
82 sign = -1
83 decoded = 0
84 for digit in s:
85 decoded = decoded * 62 + BASE62_ALPHABET.index(digit)
86 return sign * decoded
87
88
89def b64_encode(s):
90 return base64.urlsafe_b64encode(s).strip(b"=")
91
92
93def b64_decode(s):
94 pad = b"=" * (-len(s) % 4)
95 return base64.urlsafe_b64decode(s + pad)
96
97
98def base64_hmac(salt, value, key, algorithm="sha1"):
99 return b64_encode(
100 salted_hmac(salt, value, key, algorithm=algorithm).digest()
101 ).decode()
102
103
104def _cookie_signer_key(key):
105 # SECRET_KEYS items may be str or bytes.
106 return b"plain.http.cookies" + force_bytes(key)
107
108
109def get_cookie_signer(salt="plain.signing.get_cookie_signer"):
110 Signer = import_string(settings.COOKIE_SIGNING_BACKEND)
111 return Signer(
112 key=_cookie_signer_key(settings.SECRET_KEY),
113 fallback_keys=map(_cookie_signer_key, settings.SECRET_KEY_FALLBACKS),
114 salt=salt,
115 )
116
117
118class JSONSerializer:
119 """
120 Simple wrapper around json to be used in signing.dumps and
121 signing.loads.
122 """
123
124 def dumps(self, obj):
125 return json.dumps(obj, separators=(",", ":")).encode("latin-1")
126
127 def loads(self, data):
128 return json.loads(data.decode("latin-1"))
129
130
131def dumps(
132 obj, key=None, salt="plain.signing", serializer=JSONSerializer, compress=False
133):
134 """
135 Return URL-safe, hmac signed base64 compressed JSON string. If key is
136 None, use settings.SECRET_KEY instead. The hmac algorithm is the default
137 Signer algorithm.
138
139 If compress is True (not the default), check if compressing using zlib can
140 save some space. Prepend a '.' to signify compression. This is included
141 in the signature, to protect against zip bombs.
142
143 Salt can be used to namespace the hash, so that a signed string is
144 only valid for a given namespace. Leaving this at the default
145 value or re-using a salt value across different parts of your
146 application without good cause is a security risk.
147
148 The serializer is expected to return a bytestring.
149 """
150 return TimestampSigner(key=key, salt=salt).sign_object(
151 obj, serializer=serializer, compress=compress
152 )
153
154
155def loads(
156 s,
157 key=None,
158 salt="plain.signing",
159 serializer=JSONSerializer,
160 max_age=None,
161 fallback_keys=None,
162):
163 """
164 Reverse of dumps(), raise BadSignature if signature fails.
165
166 The serializer is expected to accept a bytestring.
167 """
168 return TimestampSigner(
169 key=key, salt=salt, fallback_keys=fallback_keys
170 ).unsign_object(
171 s,
172 serializer=serializer,
173 max_age=max_age,
174 )
175
176
177class Signer:
178 def __init__(
179 self,
180 *,
181 key=None,
182 sep=":",
183 salt=None,
184 algorithm="sha256",
185 fallback_keys=None,
186 ):
187 self.key = key or settings.SECRET_KEY
188 self.fallback_keys = (
189 fallback_keys
190 if fallback_keys is not None
191 else settings.SECRET_KEY_FALLBACKS
192 )
193 self.sep = sep
194 self.salt = salt or f"{self.__class__.__module__}.{self.__class__.__name__}"
195 self.algorithm = algorithm
196
197 if _SEP_UNSAFE.match(self.sep):
198 raise ValueError(
199 "Unsafe Signer separator: %r (cannot be empty or consist of "
200 "only A-z0-9-_=)" % sep,
201 )
202
203 def signature(self, value, key=None):
204 key = key or self.key
205 return base64_hmac(self.salt + "signer", value, key, algorithm=self.algorithm)
206
207 def sign(self, value):
208 return f"{value}{self.sep}{self.signature(value)}"
209
210 def unsign(self, signed_value):
211 if self.sep not in signed_value:
212 raise BadSignature('No "%s" found in value' % self.sep)
213 value, sig = signed_value.rsplit(self.sep, 1)
214 for key in [self.key, *self.fallback_keys]:
215 if constant_time_compare(sig, self.signature(value, key)):
216 return value
217 raise BadSignature('Signature "%s" does not match' % sig)
218
219 def sign_object(self, obj, serializer=JSONSerializer, compress=False):
220 """
221 Return URL-safe, hmac signed base64 compressed JSON string.
222
223 If compress is True (not the default), check if compressing using zlib
224 can save some space. Prepend a '.' to signify compression. This is
225 included in the signature, to protect against zip bombs.
226
227 The serializer is expected to return a bytestring.
228 """
229 data = serializer().dumps(obj)
230 # Flag for if it's been compressed or not.
231 is_compressed = False
232
233 if compress:
234 # Avoid zlib dependency unless compress is being used.
235 compressed = zlib.compress(data)
236 if len(compressed) < (len(data) - 1):
237 data = compressed
238 is_compressed = True
239 base64d = b64_encode(data).decode()
240 if is_compressed:
241 base64d = "." + base64d
242 return self.sign(base64d)
243
244 def unsign_object(self, signed_obj, serializer=JSONSerializer, **kwargs):
245 # Signer.unsign() returns str but base64 and zlib compression operate
246 # on bytes.
247 base64d = self.unsign(signed_obj, **kwargs).encode()
248 decompress = base64d[:1] == b"."
249 if decompress:
250 # It's compressed; uncompress it first.
251 base64d = base64d[1:]
252 data = b64_decode(base64d)
253 if decompress:
254 data = zlib.decompress(data)
255 return serializer().loads(data)
256
257
258class TimestampSigner(Signer):
259 def timestamp(self):
260 return b62_encode(int(time.time()))
261
262 def sign(self, value):
263 value = f"{value}{self.sep}{self.timestamp()}"
264 return super().sign(value)
265
266 def unsign(self, value, max_age=None):
267 """
268 Retrieve original value and check it wasn't signed more
269 than max_age seconds ago.
270 """
271 result = super().unsign(value)
272 value, timestamp = result.rsplit(self.sep, 1)
273 timestamp = b62_decode(timestamp)
274 if max_age is not None:
275 if isinstance(max_age, datetime.timedelta):
276 max_age = max_age.total_seconds()
277 # Check timestamp is not older than max_age
278 age = time.time() - timestamp
279 if age > max_age:
280 raise SignatureExpired(f"Signature age {age} > {max_age} seconds")
281 return value