sanic-security 1.11.7__tar.gz → 1.12.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sanic_security-1.12.1/LICENSE +21 -0
- sanic_security-1.11.7/README.md → sanic_security-1.12.1/PKG-INFO +118 -57
- sanic_security-1.11.7/PKG-INFO → sanic_security-1.12.1/README.md +591 -591
- sanic_security-1.12.1/pyproject.toml +31 -0
- {sanic_security-1.11.7 → sanic_security-1.12.1}/sanic_security/authentication.py +144 -140
- {sanic_security-1.11.7 → sanic_security-1.12.1}/sanic_security/authorization.py +50 -42
- {sanic_security-1.11.7 → sanic_security-1.12.1}/sanic_security/configuration.py +26 -20
- {sanic_security-1.11.7 → sanic_security-1.12.1}/sanic_security/exceptions.py +48 -22
- {sanic_security-1.11.7 → sanic_security-1.12.1}/sanic_security/models.py +114 -64
- {sanic_security-1.11.7 → sanic_security-1.12.1}/sanic_security/test/server.py +78 -26
- {sanic_security-1.11.7 → sanic_security-1.12.1}/sanic_security/test/tests.py +82 -15
- sanic_security-1.12.1/sanic_security/utils.py +86 -0
- {sanic_security-1.11.7 → sanic_security-1.12.1}/sanic_security/verification.py +20 -19
- sanic_security-1.12.1/sanic_security.egg-info/PKG-INFO +622 -0
- sanic_security-1.12.1/sanic_security.egg-info/SOURCES.txt +19 -0
- sanic_security-1.12.1/sanic_security.egg-info/dependency_links.txt +1 -0
- sanic_security-1.12.1/sanic_security.egg-info/requires.txt +16 -0
- sanic_security-1.12.1/sanic_security.egg-info/top_level.txt +1 -0
- sanic_security-1.12.1/setup.cfg +4 -0
- sanic_security-1.11.7/LICENSE +0 -661
- sanic_security-1.11.7/pyproject.toml +0 -28
- sanic_security-1.11.7/sanic_security/test/__pycache__/__init__.cpython-39.pyc +0 -0
- sanic_security-1.11.7/sanic_security/test/__pycache__/server.cpython-39.pyc +0 -0
- sanic_security-1.11.7/sanic_security/test/__pycache__/tests.cpython-39.pyc +0 -0
- sanic_security-1.11.7/sanic_security/utils.py +0 -82
- {sanic_security-1.11.7 → sanic_security-1.12.1}/sanic_security/__init__.py +0 -0
- {sanic_security-1.11.7 → sanic_security-1.12.1}/sanic_security/test/__init__.py +0 -0
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2024 Nicholas Aidan Stewart
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
@@ -1,3 +1,34 @@
|
|
1
|
+
Metadata-Version: 2.1
|
2
|
+
Name: sanic-security
|
3
|
+
Version: 1.12.1
|
4
|
+
Summary: An async security library for the Sanic framework.
|
5
|
+
Author-email: Aidan Stewart <na.stewart365@gmail.com>
|
6
|
+
Project-URL: Documentation, https://security.na-stewart.com/
|
7
|
+
Project-URL: Repository, https://github.com/na-stewart/sanic-security
|
8
|
+
Keywords: security,authentication,authorization,verification,async,sanic
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
10
|
+
Classifier: Intended Audience :: Developers
|
11
|
+
Classifier: Topic :: Security
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
13
|
+
Classifier: Programming Language :: Python
|
14
|
+
Requires-Python: >=3.8
|
15
|
+
Description-Content-Type: text/markdown
|
16
|
+
License-File: LICENSE
|
17
|
+
Requires-Dist: tortoise-orm>=0.17.0
|
18
|
+
Requires-Dist: pyjwt>=1.7.0
|
19
|
+
Requires-Dist: captcha>=0.4
|
20
|
+
Requires-Dist: pillow>=9.5.0
|
21
|
+
Requires-Dist: argon2-cffi>=20.1.0
|
22
|
+
Requires-Dist: sanic>=21.3.0
|
23
|
+
Provides-Extra: dev
|
24
|
+
Requires-Dist: httpx; extra == "dev"
|
25
|
+
Requires-Dist: black; extra == "dev"
|
26
|
+
Requires-Dist: blacken-docs; extra == "dev"
|
27
|
+
Requires-Dist: pdoc3; extra == "dev"
|
28
|
+
Requires-Dist: cryptography; extra == "dev"
|
29
|
+
Provides-Extra: crypto
|
30
|
+
Requires-Dist: cryptography>=3.3.1; extra == "crypto"
|
31
|
+
|
1
32
|
<!-- PROJECT SHIELDS -->
|
2
33
|
<!--
|
3
34
|
*** I'm using markdown "reference style" links for readability.
|
@@ -8,7 +39,7 @@
|
|
8
39
|
-->
|
9
40
|
|
10
41
|
[](https://github.com/psf/black)
|
11
|
-
[](https://pepy.tech/project/sanic-security)
|
42
|
+
[](https://pepy.tech/project/sanic-security)
|
12
43
|
[](https://anaconda.org/conda-forge/sanic-security)
|
13
44
|
|
14
45
|
|
@@ -17,7 +48,7 @@
|
|
17
48
|
<p align="center">
|
18
49
|
<h3 align="center">Sanic Security</h3>
|
19
50
|
<p align="center">
|
20
|
-
An
|
51
|
+
An async security library for the Sanic framework.
|
21
52
|
</p>
|
22
53
|
</p>
|
23
54
|
|
@@ -46,17 +77,14 @@
|
|
46
77
|
## About The Project
|
47
78
|
|
48
79
|
Sanic Security is an authentication, authorization, and verification library designed for use with [Sanic](https://github.com/huge-success/sanic).
|
49
|
-
This library contains a variety of features including:
|
50
80
|
|
51
|
-
* Login, registration, and authentication
|
81
|
+
* Login, registration, and authentication with refresh mechanisms
|
52
82
|
* Two-factor authentication
|
53
83
|
* Captcha
|
54
84
|
* Two-step verification
|
55
85
|
* Role based authorization with wildcard permissions
|
56
86
|
|
57
|
-
Please visit [security.na-stewart.com](https://security.na-stewart.com) for documentation
|
58
|
-
|
59
|
-
and check out [blog.na-stewart.com](https://github.com/na-stewart/Aidans-Page) for an example implementation.
|
87
|
+
Please visit [security.na-stewart.com](https://security.na-stewart.com) for documentation and [here for an implementation guide](https://blog.na-stewart.com/entry?id=3).
|
60
88
|
|
61
89
|
<!-- GETTING STARTED -->
|
62
90
|
## Getting Started
|
@@ -74,7 +102,7 @@ pip3 install sanic-security
|
|
74
102
|
|
75
103
|
If you are planning on encoding or decoding JWTs using certain digital signature algorithms (like RSA or ECDSA which use
|
76
104
|
the public secret and private secret), you will need to install the `cryptography` library. This can be installed explicitly, or
|
77
|
-
as
|
105
|
+
as an extra requirement.
|
78
106
|
|
79
107
|
```shell
|
80
108
|
pip3 install sanic-security[crypto]
|
@@ -109,7 +137,7 @@ You can also use the update() method like on regular dictionaries.
|
|
109
137
|
Any environment variables defined with the SANIC_SECURITY_ prefix will be applied to the config. For example, setting
|
110
138
|
SANIC_SECURITY_SECRET will be loaded by the application automatically and fed into the SECRET config variable.
|
111
139
|
|
112
|
-
You can load environment variables with a different prefix via
|
140
|
+
You can load environment variables with a different prefix via `config.load_environment_variables("NEW_PREFIX_")` method.
|
113
141
|
|
114
142
|
* Default configuration values:
|
115
143
|
|
@@ -126,15 +154,16 @@ You can load environment variables with a different prefix via calling the `conf
|
|
126
154
|
| **MAX_CHALLENGE_ATTEMPTS** | 5 | The maximum amount of session challenge attempts allowed. |
|
127
155
|
| **CAPTCHA_SESSION_EXPIRATION** | 60 | The amount of seconds till captcha session expiration on creation. Setting to 0 will disable expiration. |
|
128
156
|
| **CAPTCHA_FONT** | captcha-font.ttf | The file path to the font being used for captcha generation. |
|
129
|
-
| **TWO_STEP_SESSION_EXPIRATION** | 200 | The amount of seconds till two
|
130
|
-
| **AUTHENTICATION_SESSION_EXPIRATION** |
|
157
|
+
| **TWO_STEP_SESSION_EXPIRATION** | 200 | The amount of seconds till two-step session expiration on creation. Setting to 0 will disable expiration. |
|
158
|
+
| **AUTHENTICATION_SESSION_EXPIRATION** | 86400 | The amount of seconds till authentication session expiration on creation. Setting to 0 will disable expiration. |
|
159
|
+
| **AUTHENTICATION_REFRESH_EXPIRATION** | 2592000 | The amount of seconds till authentication refresh expiration. |
|
131
160
|
| **ALLOW_LOGIN_WITH_USERNAME** | False | Allows login via username and email. |
|
132
161
|
| **INITIAL_ADMIN_EMAIL** | admin@example.com | Email used when creating the initial admin account. |
|
133
162
|
| **INITIAL_ADMIN_PASSWORD** | admin123 | Password used when creating the initial admin account. |
|
134
163
|
|
135
164
|
## Usage
|
136
165
|
|
137
|
-
Sanic Security's authentication and verification functionality is session based. A new session will be created for the user after the user logs in or requests some form of verification (two-step, captcha). The session data is then encoded into a JWT and stored on a cookie on the user’s browser. The session cookie
|
166
|
+
Sanic Security's authentication and verification functionality is session based. A new session will be created for the user after the user logs in or requests some form of verification (two-step, captcha). The session data is then encoded into a JWT and stored on a cookie on the user’s browser. The session cookie is then sent
|
138
167
|
along with every subsequent request. The server can then compare the session stored on the cookie against the session information stored in the database to verify user’s identity and send a response with the corresponding state.
|
139
168
|
|
140
169
|
The tables in the below examples represent example [request form-data](https://sanicframework.org/en/guide/basics/request.html#form).
|
@@ -151,7 +180,7 @@ This account can be logged into and has complete authoritative access. Login cre
|
|
151
180
|
app.run(host="127.0.0.1", port=8000)
|
152
181
|
```
|
153
182
|
|
154
|
-
* Registration (With
|
183
|
+
* Registration (With two-step account verification)
|
155
184
|
|
156
185
|
Phone can be null or empty.
|
157
186
|
|
@@ -168,11 +197,11 @@ async def on_register(request):
|
|
168
197
|
account = await register(request)
|
169
198
|
two_step_session = await request_two_step_verification(request, account)
|
170
199
|
await email_code(
|
171
|
-
account.email, two_step_session.code # Code =
|
200
|
+
account.email, two_step_session.code # Code = 197251
|
172
201
|
) # Custom method for emailing verification code.
|
173
202
|
response = json(
|
174
203
|
"Registration successful! Email verification required.",
|
175
|
-
two_step_session.
|
204
|
+
two_step_session.json,
|
176
205
|
)
|
177
206
|
two_step_session.encode(response)
|
178
207
|
return response
|
@@ -184,18 +213,16 @@ Verifies the client's account via two-step session code.
|
|
184
213
|
|
185
214
|
| Key | Value |
|
186
215
|
|----------|--------|
|
187
|
-
| **code** |
|
216
|
+
| **code** | 197251 |
|
188
217
|
|
189
218
|
```python
|
190
219
|
@app.post("api/security/verify")
|
191
220
|
async def on_verify(request):
|
192
221
|
two_step_session = await verify_account(request)
|
193
|
-
return json(
|
194
|
-
"You have verified your account and may login!", two_step_session.bearer.json
|
195
|
-
)
|
222
|
+
return json("You have verified your account and may login!", two_step_session.json)
|
196
223
|
```
|
197
224
|
|
198
|
-
* Login (With
|
225
|
+
* Login (With two-factor authentication)
|
199
226
|
|
200
227
|
Login credentials are retrieved via the Authorization header. Credentials are constructed by first combining the
|
201
228
|
username and the password with a colon (aladdin:opensesame), and then by encoding the resulting string in base64
|
@@ -211,11 +238,11 @@ async def on_login(request):
|
|
211
238
|
request, authentication_session.bearer
|
212
239
|
)
|
213
240
|
await email_code(
|
214
|
-
authentication_session.bearer.email, two_step_session.code # Code =
|
241
|
+
authentication_session.bearer.email, two_step_session.code # Code = 197251
|
215
242
|
) # Custom method for emailing verification code.
|
216
243
|
response = json(
|
217
244
|
"Login successful! Two-factor authentication required.",
|
218
|
-
authentication_session.
|
245
|
+
authentication_session.json,
|
219
246
|
)
|
220
247
|
authentication_session.encode(response)
|
221
248
|
two_step_session.encode(response)
|
@@ -228,15 +255,30 @@ Fulfills client authentication session's second factor requirement via two-step
|
|
228
255
|
|
229
256
|
| Key | Value |
|
230
257
|
|----------|--------|
|
231
|
-
| **code** |
|
258
|
+
| **code** | 197251 |
|
232
259
|
|
233
260
|
```python
|
234
|
-
@app.post("api/security/
|
261
|
+
@app.post("api/security/fulfill-2fa")
|
235
262
|
async def on_two_factor_authentication(request):
|
236
263
|
authentication_session = await fulfill_second_factor(request)
|
237
264
|
response = json(
|
238
265
|
"Authentication session second-factor fulfilled! You are now authenticated.",
|
239
|
-
authentication_session.
|
266
|
+
authentication_session.json,
|
267
|
+
)
|
268
|
+
authentication_session.encode(response)
|
269
|
+
return response
|
270
|
+
```
|
271
|
+
|
272
|
+
* Anonymous Login
|
273
|
+
|
274
|
+
Simply create a new session and encode it.
|
275
|
+
|
276
|
+
```python
|
277
|
+
@app.post("api/security/login/anon")
|
278
|
+
async def on_anonymous_login(request):
|
279
|
+
authentication_session = await AuthenticationSession.new(request)
|
280
|
+
response = json(
|
281
|
+
"Anonymous client now associated with session!", authentication_session.json
|
240
282
|
)
|
241
283
|
authentication_session.encode(response)
|
242
284
|
return response
|
@@ -248,32 +290,57 @@ async def on_two_factor_authentication(request):
|
|
248
290
|
@app.post("api/security/logout")
|
249
291
|
async def on_logout(request):
|
250
292
|
authentication_session = await logout(request)
|
251
|
-
|
252
|
-
return response
|
293
|
+
return json("Logout successful!", authentication_session.json)
|
253
294
|
```
|
254
295
|
|
255
296
|
* Authenticate
|
256
297
|
|
298
|
+
New/Refreshed session will be returned if expired, requires encoding.
|
299
|
+
|
257
300
|
```python
|
258
301
|
@app.post("api/security/auth")
|
259
302
|
async def on_authenticate(request):
|
260
303
|
authentication_session = await authenticate(request)
|
261
|
-
|
304
|
+
response = json(
|
262
305
|
"You have been authenticated.",
|
263
|
-
authentication_session.
|
306
|
+
authentication_session.json,
|
264
307
|
)
|
308
|
+
if authentication_session.is_refresh:
|
309
|
+
authentication_session.encode(response)
|
310
|
+
return response
|
265
311
|
```
|
266
312
|
|
267
|
-
* Requires Authentication (This method is not called directly and instead used as a decorator
|
313
|
+
* Requires Authentication (This method is not called directly and instead used as a decorator)
|
314
|
+
|
315
|
+
New/Refreshed session will be returned if expired, requires encoding.
|
268
316
|
|
269
317
|
```python
|
270
318
|
@app.post("api/security/auth")
|
271
319
|
@requires_authentication
|
272
320
|
async def on_authenticate(request):
|
273
|
-
|
321
|
+
authentication_session = request.ctx.authentication_session
|
322
|
+
response = json(
|
274
323
|
"You have been authenticated.",
|
275
|
-
|
324
|
+
authentication_session.json,
|
276
325
|
)
|
326
|
+
if authentication_session.is_refresh:
|
327
|
+
authentication_session.encode(response)
|
328
|
+
return response
|
329
|
+
```
|
330
|
+
|
331
|
+
* Authentication Refresh Middleware
|
332
|
+
|
333
|
+
If it's inconvenient to encode the refreshed session during authentication, it can also be done automatically via middleware.
|
334
|
+
|
335
|
+
```python
|
336
|
+
@app.on_response
|
337
|
+
async def authentication_refresh_encoder(request, response):
|
338
|
+
try:
|
339
|
+
authentication_session = request.ctx.authentication_session
|
340
|
+
if authentication_session.is_refresh:
|
341
|
+
authentication_session.encode(response)
|
342
|
+
except AttributeError:
|
343
|
+
pass
|
277
344
|
```
|
278
345
|
|
279
346
|
## Captcha
|
@@ -285,17 +352,13 @@ downloading a .ttf font and defining the file's path in the configuration.
|
|
285
352
|
|
286
353
|
[Recommended Font](https://www.1001fonts.com/source-sans-pro-font.html)
|
287
354
|
|
288
|
-
Captcha challenge example:
|
289
|
-
|
290
|
-
[](https://github.com/na-stewart/sanic-security/blob/main/images/captcha.png?raw=true)
|
291
|
-
|
292
355
|
* Request Captcha
|
293
356
|
|
294
357
|
```python
|
295
358
|
@app.get("api/security/captcha")
|
296
359
|
async def on_captcha_img_request(request):
|
297
360
|
captcha_session = await request_captcha(request)
|
298
|
-
response = captcha_session.get_image() # Captcha:
|
361
|
+
response = captcha_session.get_image() # Captcha: 192731
|
299
362
|
captcha_session.encode(response)
|
300
363
|
return response
|
301
364
|
```
|
@@ -304,7 +367,7 @@ async def on_captcha_img_request(request):
|
|
304
367
|
|
305
368
|
| Key | Value |
|
306
369
|
|-------------|--------|
|
307
|
-
| **captcha** |
|
370
|
+
| **captcha** | 192731 |
|
308
371
|
|
309
372
|
```python
|
310
373
|
@app.post("api/security/captcha")
|
@@ -313,14 +376,14 @@ async def on_captcha(request):
|
|
313
376
|
return json("Captcha attempt successful!", captcha_session.json)
|
314
377
|
```
|
315
378
|
|
316
|
-
* Requires Captcha (This method is not called directly and instead used as a decorator
|
379
|
+
* Requires Captcha (This method is not called directly and instead used as a decorator)
|
317
380
|
|
318
381
|
| Key | Value |
|
319
382
|
|-------------|--------|
|
320
|
-
| **captcha** |
|
383
|
+
| **captcha** | 192731 |
|
321
384
|
|
322
385
|
```python
|
323
|
-
@app.post("
|
386
|
+
@app.post("api/security/captcha")
|
324
387
|
@requires_captcha
|
325
388
|
async def on_captcha(request):
|
326
389
|
return json("Captcha attempt successful!", request.ctx.captcha_session.json)
|
@@ -339,48 +402,46 @@ Two-step verification should be integrated with other custom functionality. For
|
|
339
402
|
```python
|
340
403
|
@app.post("api/security/two-step/request")
|
341
404
|
async def on_two_step_request(request):
|
342
|
-
two_step_session = await request_two_step_verification(request)
|
405
|
+
two_step_session = await request_two_step_verification(request) # Code = 197251
|
343
406
|
await email_code(
|
344
|
-
|
407
|
+
two_step_session.bearer.email, two_step_session.code
|
345
408
|
) # Custom method for emailing verification code.
|
346
|
-
response = json("Verification request successful!", two_step_session.
|
409
|
+
response = json("Verification request successful!", two_step_session.json)
|
347
410
|
two_step_session.encode(response)
|
348
411
|
return response
|
349
|
-
```
|
412
|
+
```
|
350
413
|
|
351
414
|
* Resend Two-step Verification Code
|
352
415
|
|
353
416
|
```python
|
354
417
|
@app.post("api/security/two-step/resend")
|
355
418
|
async def on_two_step_resend(request):
|
356
|
-
two_step_session = await TwoStepSession.decode(request)
|
419
|
+
two_step_session = await TwoStepSession.decode(request) # Code = 197251
|
357
420
|
await email_code(
|
358
|
-
|
421
|
+
two_step_session.bearer.email, two_step_session.code
|
359
422
|
) # Custom method for emailing verification code.
|
360
|
-
return json("Verification code resend successful!", two_step_session.
|
423
|
+
return json("Verification code resend successful!", two_step_session.json)
|
361
424
|
```
|
362
425
|
|
363
426
|
* Two-step Verification
|
364
427
|
|
365
428
|
| Key | Value |
|
366
429
|
|----------|--------|
|
367
|
-
| **code** |
|
430
|
+
| **code** | 197251 |
|
368
431
|
|
369
432
|
```python
|
370
433
|
@app.post("api/security/two-step")
|
371
434
|
async def on_two_step_verification(request):
|
372
435
|
two_step_session = await two_step_verification(request)
|
373
|
-
response = json(
|
374
|
-
"Two-step verification attempt successful!", two_step_session.bearer.json
|
375
|
-
)
|
436
|
+
response = json("Two-step verification attempt successful!", two_step_session.json)
|
376
437
|
return response
|
377
438
|
```
|
378
439
|
|
379
|
-
* Requires Two-step Verification (This method is not called directly and instead used as a decorator
|
440
|
+
* Requires Two-step Verification (This method is not called directly and instead used as a decorator)
|
380
441
|
|
381
442
|
| Key | Value |
|
382
443
|
|----------|--------|
|
383
|
-
| **code** |
|
444
|
+
| **code** | 197251 |
|
384
445
|
|
385
446
|
```python
|
386
447
|
@app.post("api/security/two-step")
|
@@ -388,7 +449,7 @@ async def on_two_step_verification(request):
|
|
388
449
|
async def on_two_step_verification(request):
|
389
450
|
response = json(
|
390
451
|
"Two-step verification attempt successful!",
|
391
|
-
request.ctx.two_step_session.
|
452
|
+
request.ctx.two_step_session.json,
|
392
453
|
)
|
393
454
|
return response
|
394
455
|
```
|
@@ -445,7 +506,7 @@ async def on_check_roles(request):
|
|
445
506
|
return text("Account is authorized.")
|
446
507
|
```
|
447
508
|
|
448
|
-
* Require Roles (This method is not called directly and instead used as a decorator
|
509
|
+
* Require Roles (This method is not called directly and instead used as a decorator)
|
449
510
|
|
450
511
|
```python
|
451
512
|
@app.post("api/security/roles")
|
@@ -532,7 +593,7 @@ Contributions are what make the open source community such an amazing place to b
|
|
532
593
|
<!-- LICENSE -->
|
533
594
|
## License
|
534
595
|
|
535
|
-
Distributed under the
|
596
|
+
Distributed under the MIT License. See `LICENSE` for more information.
|
536
597
|
|
537
598
|
<!-- Versioning -->
|
538
599
|
## Versioning
|