tina4-python 0.2.133__tar.gz → 0.2.134__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.
- {tina4_python-0.2.133 → tina4_python-0.2.134}/.gitignore +1 -0
- tina4_python-0.2.134/PKG-INFO +84 -0
- tina4_python-0.2.134/README.md +65 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/pyproject.toml +62 -44
- tina4_python-0.2.134/tina4_python/Auth.py +323 -0
- tina4_python-0.2.134/tina4_python/CRUD.py +388 -0
- tina4_python-0.2.134/tina4_python/Constant.py +90 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Database.py +23 -21
- tina4_python-0.2.134/tina4_python/Debug.py +119 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Env.py +2 -0
- tina4_python-0.2.134/tina4_python/FieldTypes.py +272 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/HtmlElement.py +7 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Localization.py +1 -1
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Migration.py +6 -6
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/ORM.py +1 -246
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Queue.py +2 -2
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Router.py +13 -14
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Session.py +14 -14
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Template.py +4 -4
- tina4_python-0.2.134/tina4_python/Webserver.py +583 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/__init__.py +191 -100
- tina4_python-0.2.134/tina4_python/cli.py +95 -0
- tina4_python-0.2.133/PKG-INFO +0 -465
- tina4_python-0.2.133/README.md +0 -446
- tina4_python-0.2.133/tina4_python/Auth.py +0 -223
- tina4_python-0.2.133/tina4_python/CRUD.py +0 -299
- tina4_python-0.2.133/tina4_python/Constant.py +0 -43
- tina4_python-0.2.133/tina4_python/Debug.py +0 -126
- tina4_python-0.2.133/tina4_python/Webserver.py +0 -432
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/DatabaseResult.py +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/DatabaseTypes.py +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Messages.py +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/MiddleWare.py +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Request.py +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Response.py +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/ShellColors.py +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Swagger.py +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Websocket.py +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/messages.pot +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/css/readme.md +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/images/403.png +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/images/404.png +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/images/500.png +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/images/logo.png +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/images/readme.md +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/js/readme.md +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/js/reconnecting-websocket.js +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/js/tina4helper.js +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/templates/readme.md +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tina4-python
|
|
3
|
+
Version: 0.2.134
|
|
4
|
+
Summary: Tina4Python - This is not another framework for Python
|
|
5
|
+
Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
|
|
6
|
+
Requires-Python: <4.0,>=3.12
|
|
7
|
+
Requires-Dist: asyncer>=0.0.8
|
|
8
|
+
Requires-Dist: bcrypt<5.0.0,>=4.2.1
|
|
9
|
+
Requires-Dist: cryptography<45.0.0,>=44.0.0
|
|
10
|
+
Requires-Dist: jinja2<4.0.0,>=3.1.5
|
|
11
|
+
Requires-Dist: libsass<0.24.0,>=0.23.0
|
|
12
|
+
Requires-Dist: litequeue<0.10,>=0.9
|
|
13
|
+
Requires-Dist: pyjwt<3.0.0,>=2.10.1
|
|
14
|
+
Requires-Dist: python-dotenv<2.0.0,>=1.0.1
|
|
15
|
+
Requires-Dist: simple-websocket<2.0.0,>=1.1.0
|
|
16
|
+
Requires-Dist: uvicorn>=0.38.0
|
|
17
|
+
Requires-Dist: watchdog<7.0.0,>=6.0.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# Tina4 Python — This is not a framework
|
|
21
|
+
|
|
22
|
+
Laravel joy. Python speed. 10× less code.
|
|
23
|
+
|
|
24
|
+
```python app.py
|
|
25
|
+
from tina4_python import run_web_server
|
|
26
|
+
from tina4_python.Router import get
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@get("/")
|
|
30
|
+
async def get_hello_world(request, response):
|
|
31
|
+
return response("Hello World!")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def app():
|
|
35
|
+
run_web_server()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
app()
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
That’s it. Save the code above as `app.py`, run it, and you have a fully working Tina4 Python web server.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install tina4-python
|
|
46
|
+
python app.py
|
|
47
|
+
# → http://localhost:7145
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
You just built your first Tina4 app — zero configuration, zero classes, zero boilerplate.
|
|
51
|
+
|
|
52
|
+
## Features
|
|
53
|
+
|
|
54
|
+
- Full ASGI compliance, use any ASGI compliant webserver
|
|
55
|
+
- Full async support out of the box
|
|
56
|
+
- Built in JWT and Session handling
|
|
57
|
+
- Automatic Swagger docs at `/swagger`
|
|
58
|
+
- Instant CRUD interfaces with one line: `result.to_crud(request)`
|
|
59
|
+
- Built-in Twig templating, migrations, WebSockets, authentication and middleware
|
|
60
|
+
- Works with SQLite, PostgreSQL, MySQL, MariaDB, MSSQL, Firebird
|
|
61
|
+
- Hot reload in development (`uv run python -m jurigged app.py`)
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install tina4-python
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Documentation
|
|
70
|
+
|
|
71
|
+
https://tina4.com/
|
|
72
|
+
|
|
73
|
+
## Community
|
|
74
|
+
|
|
75
|
+
- GitHub: https://github.com/tina4stack/tina4-python
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT © 2007 – 2025 Tina4 Stack
|
|
80
|
+
https://opensource.org/licenses/MIT
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
**Tina4** – The framework that keeps out of the way of your coding.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Tina4 Python — This is not a framework
|
|
2
|
+
|
|
3
|
+
Laravel joy. Python speed. 10× less code.
|
|
4
|
+
|
|
5
|
+
```python app.py
|
|
6
|
+
from tina4_python import run_web_server
|
|
7
|
+
from tina4_python.Router import get
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@get("/")
|
|
11
|
+
async def get_hello_world(request, response):
|
|
12
|
+
return response("Hello World!")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def app():
|
|
16
|
+
run_web_server()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
app()
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
That’s it. Save the code above as `app.py`, run it, and you have a fully working Tina4 Python web server.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install tina4-python
|
|
27
|
+
python app.py
|
|
28
|
+
# → http://localhost:7145
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
You just built your first Tina4 app — zero configuration, zero classes, zero boilerplate.
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- Full ASGI compliance, use any ASGI compliant webserver
|
|
36
|
+
- Full async support out of the box
|
|
37
|
+
- Built in JWT and Session handling
|
|
38
|
+
- Automatic Swagger docs at `/swagger`
|
|
39
|
+
- Instant CRUD interfaces with one line: `result.to_crud(request)`
|
|
40
|
+
- Built-in Twig templating, migrations, WebSockets, authentication and middleware
|
|
41
|
+
- Works with SQLite, PostgreSQL, MySQL, MariaDB, MSSQL, Firebird
|
|
42
|
+
- Hot reload in development (`uv run python -m jurigged app.py`)
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install tina4-python
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Documentation
|
|
51
|
+
|
|
52
|
+
https://tina4.com/
|
|
53
|
+
|
|
54
|
+
## Community
|
|
55
|
+
|
|
56
|
+
- GitHub: https://github.com/tina4stack/tina4-python
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT © 2007 – 2025 Tina4 Stack
|
|
61
|
+
https://opensource.org/licenses/MIT
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
**Tina4** – The framework that keeps out of the way of your coding.
|
|
@@ -1,44 +1,62 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "tina4-python"
|
|
3
|
-
version = "0.2.
|
|
4
|
-
description = "Tina4Python - This is not another framework for Python"
|
|
5
|
-
authors = [
|
|
6
|
-
{name = "Andre van Zuydam",email = "andrevanzuydam@gmail.com"}
|
|
7
|
-
]
|
|
8
|
-
readme = "README.md"
|
|
9
|
-
requires-python = ">=3.12,<4.0"
|
|
10
|
-
dependencies = [
|
|
11
|
-
"jinja2>=3.1.5,<4.0.0",
|
|
12
|
-
"libsass (>=0.23.0,<0.24.0)",
|
|
13
|
-
"python-dotenv (>=1.0.1,<2.0.0)",
|
|
14
|
-
"pyjwt (>=2.10.1,<3.0.0)",
|
|
15
|
-
"cryptography (>=44.0.0,<45.0.0)",
|
|
16
|
-
"watchdog (>=6.0.0,<7.0.0)",
|
|
17
|
-
"bcrypt (>=4.2.1,<5.0.0)",
|
|
18
|
-
"litequeue (>=0.9,<0.10)",
|
|
19
|
-
"simple-websocket (>=1.1.0,<2.0.0)",
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
]
|
|
23
|
-
|
|
24
|
-
[dependency-groups]
|
|
25
|
-
dev = [
|
|
26
|
-
"flake8>=7.2.0",
|
|
27
|
-
"jurigged>=0.6.0",
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
build-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
1
|
+
[project]
|
|
2
|
+
name = "tina4-python"
|
|
3
|
+
version = "0.2.134"
|
|
4
|
+
description = "Tina4Python - This is not another framework for Python"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Andre van Zuydam",email = "andrevanzuydam@gmail.com"}
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.12,<4.0"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"jinja2>=3.1.5,<4.0.0",
|
|
12
|
+
"libsass (>=0.23.0,<0.24.0)",
|
|
13
|
+
"python-dotenv (>=1.0.1,<2.0.0)",
|
|
14
|
+
"pyjwt (>=2.10.1,<3.0.0)",
|
|
15
|
+
"cryptography (>=44.0.0,<45.0.0)",
|
|
16
|
+
"watchdog (>=6.0.0,<7.0.0)",
|
|
17
|
+
"bcrypt (>=4.2.1,<5.0.0)",
|
|
18
|
+
"litequeue (>=0.9,<0.10)",
|
|
19
|
+
"simple-websocket (>=1.1.0,<2.0.0)",
|
|
20
|
+
"asyncer>=0.0.8",
|
|
21
|
+
"uvicorn>=0.38.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"flake8>=7.2.0",
|
|
27
|
+
"jurigged>=0.6.0",
|
|
28
|
+
"mkdocs>=1.6.1",
|
|
29
|
+
"mysql-connector-python>=9.3.0",
|
|
30
|
+
"psycopg2-binary>=2.9.10",
|
|
31
|
+
"pydoc-markdown>=4.8.2",
|
|
32
|
+
"pytest>=8.3.5",
|
|
33
|
+
"ruff>=0.11.9",
|
|
34
|
+
"safety>=3.5.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["hatchling"]
|
|
39
|
+
build-backend = "hatchling.build"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build]
|
|
42
|
+
include = [
|
|
43
|
+
"tina4_python/**/*"
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
tina4 = "tina4_python.cli:main"
|
|
48
|
+
|
|
49
|
+
[[tool.pydoc-markdown.loaders]]
|
|
50
|
+
type = "python"
|
|
51
|
+
search_path = [ "./tina4_python" ]
|
|
52
|
+
|
|
53
|
+
[tool.pydoc-markdown.renderer]
|
|
54
|
+
type = "mkdocs"
|
|
55
|
+
site_name= "Tina4Python"
|
|
56
|
+
|
|
57
|
+
[[tool.pydoc-markdown.renderer.pages]]
|
|
58
|
+
title = "API Documentation"
|
|
59
|
+
name = "index"
|
|
60
|
+
|
|
61
|
+
contents = [ "*" ]
|
|
62
|
+
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Tina4 - This is not a 4ramework.
|
|
3
|
+
# Copy-right 2007 - current Tina4
|
|
4
|
+
# License: MIT https://opensource.org/licenses/MIT
|
|
5
|
+
#
|
|
6
|
+
# flake8: noqa: E501
|
|
7
|
+
import datetime
|
|
8
|
+
import os
|
|
9
|
+
import jwt
|
|
10
|
+
import bcrypt
|
|
11
|
+
from json import JSONEncoder
|
|
12
|
+
from cryptography import x509
|
|
13
|
+
from cryptography.x509 import NameOID
|
|
14
|
+
from cryptography.hazmat.primitives import serialization, hashes
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
16
|
+
from cryptography.hazmat.backends import default_backend
|
|
17
|
+
|
|
18
|
+
class AuthJSONSerializer(JSONEncoder):
|
|
19
|
+
"""
|
|
20
|
+
Custom JSON encoder used by PyJWT when serializing payload objects.
|
|
21
|
+
|
|
22
|
+
Ensures that ``datetime.datetime`` instances are correctly converted to ISO-8601
|
|
23
|
+
strings so they can be embedded in JWT claims.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def default(self, o):
|
|
27
|
+
if isinstance(o, datetime.datetime):
|
|
28
|
+
return o.isoformat()
|
|
29
|
+
return super().default(o)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Auth:
|
|
33
|
+
"""
|
|
34
|
+
Authentication & authorization helper for Tina4 projects.
|
|
35
|
+
|
|
36
|
+
Handles:
|
|
37
|
+
- Password hashing/verification (bcrypt)
|
|
38
|
+
- Automatic creation and loading of an RSA private/public key pair
|
|
39
|
+
- Self-signed certificate generation for local HTTPS development
|
|
40
|
+
- Signing JWT tokens with RS256 (private key)
|
|
41
|
+
- Verifying JWT tokens with RS256 (public key)
|
|
42
|
+
- Simple API-KEY fallback validation
|
|
43
|
+
|
|
44
|
+
Keys and certificates are stored in ``<root_path>/secrets/``:
|
|
45
|
+
- private.key → encrypted PEM private key
|
|
46
|
+
- public.key → PEM public key (unencrypted)
|
|
47
|
+
- domain.cert → self-signed certificate (for local dev servers)
|
|
48
|
+
|
|
49
|
+
Environment variables used:
|
|
50
|
+
- ``SECRET`` → passphrase used to encrypt the private key
|
|
51
|
+
- ``API_KEY`` → optional static API key (checked before JWT)
|
|
52
|
+
- ``TINA4_TOKEN_LIMIT`` → default token lifetime in minutes (default: 2)
|
|
53
|
+
- Country/State/City/Organization/Domain variables for the cert
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
# Class-level attributes (set in __init__)
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
secret: str | None = None # Passphrase for private key encryption
|
|
60
|
+
private_key: str = None # Path to encrypted private key file
|
|
61
|
+
public_key: str = None # Path to public key file
|
|
62
|
+
self_signed: str = None # Path to self-signed cert file
|
|
63
|
+
root_path: str = None # Project root directory
|
|
64
|
+
|
|
65
|
+
# Cached key objects (avoid reloading from disk on every request)
|
|
66
|
+
loaded_private_key = None
|
|
67
|
+
loaded_public_key = None
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Password handling (bcrypt)
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
def hash_password(self, text: str) -> str:
|
|
73
|
+
"""
|
|
74
|
+
Generate a bcrypt hash for the given plain-text password.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
text (str): Plain-text password.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
str: bcrypt hash (as UTF-8 string). Safe to store in a database.
|
|
81
|
+
"""
|
|
82
|
+
password_bytes = text.encode("utf-8")
|
|
83
|
+
salt = bcrypt.gensalt()
|
|
84
|
+
return bcrypt.hashpw(password_bytes, salt).decode("utf-8")
|
|
85
|
+
|
|
86
|
+
def check_password(self, password_hash: str, text: str) -> bool:
|
|
87
|
+
"""
|
|
88
|
+
Verify a plain-text password against a previously created bcrypt hash.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
password_hash (str): Hash returned by :meth:`hash_password`.
|
|
92
|
+
text (str): Plain-text password to verify.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
bool: ``True`` if the password matches the hash.
|
|
96
|
+
"""
|
|
97
|
+
password_bytes = text.encode("utf-8")
|
|
98
|
+
return bcrypt.checkpw(password_bytes, password_hash.encode("utf-8"))
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Private / public key loading (with caching)
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
def load_private_key(self):
|
|
104
|
+
"""
|
|
105
|
+
Load (and cache) the RSA private key from ``secrets/private.key``.
|
|
106
|
+
|
|
107
|
+
The key is encrypted with the passphrase stored in ``self.secret``
|
|
108
|
+
(which comes from the environment variable ``SECRET``).
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey
|
|
112
|
+
"""
|
|
113
|
+
if self.loaded_private_key:
|
|
114
|
+
return self.loaded_private_key
|
|
115
|
+
|
|
116
|
+
with open(self.private_key, "rb") as f:
|
|
117
|
+
private_key = serialization.load_pem_private_key(
|
|
118
|
+
f.read(),
|
|
119
|
+
password=self.secret.encode(),
|
|
120
|
+
backend=default_backend(),
|
|
121
|
+
)
|
|
122
|
+
self.loaded_private_key = private_key
|
|
123
|
+
return private_key
|
|
124
|
+
|
|
125
|
+
def load_public_key(self):
|
|
126
|
+
"""
|
|
127
|
+
Load (and cache) the RSA public key from ``secrets/public.key``.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey
|
|
131
|
+
"""
|
|
132
|
+
if self.loaded_public_key:
|
|
133
|
+
return self.loaded_public_key
|
|
134
|
+
|
|
135
|
+
with open(self.public_key, "rb") as f:
|
|
136
|
+
public_key = serialization.load_pem_public_key(
|
|
137
|
+
f.read(), backend=default_backend()
|
|
138
|
+
)
|
|
139
|
+
self.loaded_public_key = public_key
|
|
140
|
+
return public_key
|
|
141
|
+
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
# Constructor – creates secrets folder & keys if missing
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
def __init__(self, root_path: str):
|
|
146
|
+
"""
|
|
147
|
+
Initialise the Auth helper and ensure cryptographic material exists.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
root_path (str): Absolute path to the project root (where the
|
|
151
|
+
``secrets`` folder will be created).
|
|
152
|
+
"""
|
|
153
|
+
self.root_path = root_path
|
|
154
|
+
self.secret = os.environ.get("SECRET", "{self.secret}")
|
|
155
|
+
self.private_key = os.path.join(root_path, "secrets", "private.key")
|
|
156
|
+
self.public_key = os.path.join(root_path, "secrets", "public.key")
|
|
157
|
+
self.self_signed = os.path.join(root_path, "secrets", "domain.cert")
|
|
158
|
+
|
|
159
|
+
# Ensure secrets directory exists
|
|
160
|
+
os.makedirs(os.path.join(root_path, "secrets"), exist_ok=True)
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
# 1. Private key – generate if missing
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
if not os.path.isfile(self.private_key):
|
|
166
|
+
private_key = rsa.generate_private_key(
|
|
167
|
+
public_exponent=65537,
|
|
168
|
+
key_size=2048,
|
|
169
|
+
)
|
|
170
|
+
with open(self.private_key, "wb") as f:
|
|
171
|
+
f.write(private_key.private_bytes(
|
|
172
|
+
encoding=serialization.Encoding.PEM,
|
|
173
|
+
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
174
|
+
encryption_algorithm=serialization.BestAvailableEncryption(self.secret.encode()),
|
|
175
|
+
))
|
|
176
|
+
else:
|
|
177
|
+
private_key = self.load_private_key()
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------
|
|
180
|
+
# 2. Public key – derive from private key if missing
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
if not os.path.isfile(self.public_key):
|
|
183
|
+
public_key = private_key.public_key()
|
|
184
|
+
public_pem = public_key.public_bytes(
|
|
185
|
+
encoding=serialization.Encoding.PEM,
|
|
186
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
187
|
+
)
|
|
188
|
+
with open(self.public_key, "wb") as f:
|
|
189
|
+
f.write(public_pem)
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
# 3. Self-signed certificate (useful for local HTTPS servers)
|
|
193
|
+
# ------------------------------------------------------------------
|
|
194
|
+
if not os.path.isfile(self.self_signed):
|
|
195
|
+
subject = issuer = x509.Name(
|
|
196
|
+
[
|
|
197
|
+
x509.NameAttribute(NameOID.COUNTRY_NAME, os.environ.get("COUNTRY", "ZA")),
|
|
198
|
+
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, os.environ.get("STATE", "WESTERN CAPE")),
|
|
199
|
+
x509.NameAttribute(NameOID.LOCALITY_NAME, os.environ.get("CITY", "CAPE TOWN")),
|
|
200
|
+
x509.NameAttribute(NameOID.ORGANIZATION_NAME, os.environ.get("ORGANIZATION", "Tina4")),
|
|
201
|
+
x509.NameAttribute(NameOID.COMMON_NAME, os.environ.get("DOMAIN_NAME", "localhost")),
|
|
202
|
+
]
|
|
203
|
+
)
|
|
204
|
+
cert = (
|
|
205
|
+
x509.CertificateBuilder()
|
|
206
|
+
.subject_name(subject)
|
|
207
|
+
.issuer_name(issuer)
|
|
208
|
+
.public_key(private_key.public_key())
|
|
209
|
+
.serial_number(x509.random_serial_number())
|
|
210
|
+
.not_valid_before(datetime.datetime.now(datetime.timezone.utc))
|
|
211
|
+
.not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=99999))
|
|
212
|
+
.add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False)
|
|
213
|
+
.sign(private_key, hashes.SHA256())
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
with open(self.self_signed, "wb") as f:
|
|
217
|
+
f.write(cert.public_bytes(serialization.Encoding.PEM))
|
|
218
|
+
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
# JWT creation & verification
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
def get_token(self, payload_data: dict, expiry_minutes: int = 0) -> str:
|
|
223
|
+
"""
|
|
224
|
+
Create a signed JWT (RS256) containing the supplied payload.
|
|
225
|
+
|
|
226
|
+
If ``expires`` is not present in ``payload_data`` an expiration claim
|
|
227
|
+
will be added automatically using ``TINA4_TOKEN_LIMIT`` (default 2 minutes)
|
|
228
|
+
or the value supplied via ``expiry_minutes``.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
payload_data (dict): Claims to embed in the token.
|
|
232
|
+
expiry_minutes (int): Override default token lifetime (0 = use env default).
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
str: Signed JWT (compact serialization).
|
|
236
|
+
"""
|
|
237
|
+
private_key = self.load_private_key()
|
|
238
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
239
|
+
|
|
240
|
+
if "expires" not in payload_data:
|
|
241
|
+
token_limit_minutes = int(os.environ.get("TINA4_TOKEN_LIMIT", 2))
|
|
242
|
+
if expiry_minutes != 0:
|
|
243
|
+
token_limit_minutes = expiry_minutes
|
|
244
|
+
expiry_time = now + datetime.timedelta(minutes=token_limit_minutes)
|
|
245
|
+
payload_data["expires"] = expiry_time.isoformat()
|
|
246
|
+
|
|
247
|
+
token = jwt.encode(
|
|
248
|
+
payload=payload_data,
|
|
249
|
+
key=private_key,
|
|
250
|
+
algorithm="RS256",
|
|
251
|
+
json_encoder=AuthJSONSerializer,
|
|
252
|
+
)
|
|
253
|
+
return token
|
|
254
|
+
|
|
255
|
+
def get_payload(self, token: str) -> dict | None:
|
|
256
|
+
"""
|
|
257
|
+
Decode a JWT and return its payload (without verification of expiry).
|
|
258
|
+
|
|
259
|
+
Used when you only need the claims and will perform your own validation.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
token (str): JWT to decode.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
dict | None: Payload dictionary or ``None`` on invalid signature.
|
|
266
|
+
"""
|
|
267
|
+
public_key = self.load_public_key()
|
|
268
|
+
try:
|
|
269
|
+
payload = jwt.decode(token, key=public_key, algorithms=["RS256"])
|
|
270
|
+
return payload
|
|
271
|
+
except jwt.InvalidSignatureError:
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
def validate(self, token: str) -> bool:
|
|
275
|
+
"""
|
|
276
|
+
Full token validation.
|
|
277
|
+
|
|
278
|
+
Checks:
|
|
279
|
+
1. Optional static ``API_KEY`` environment variable (quick bypass)
|
|
280
|
+
2. RS256 signature using the public key
|
|
281
|
+
3. Presence and validity of the ``expires`` claim
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
token (str): Bearer token.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
bool: ``True`` if the token is valid and not expired.
|
|
288
|
+
"""
|
|
289
|
+
# Simple API-KEY fallback (useful for quick internal scripts)
|
|
290
|
+
if os.environ.get("API_KEY") and token == os.environ.get("API_KEY"):
|
|
291
|
+
return True
|
|
292
|
+
|
|
293
|
+
public_key = self.load_public_key()
|
|
294
|
+
try:
|
|
295
|
+
payload = jwt.decode(token, key=public_key, algorithms=["RS256"])
|
|
296
|
+
|
|
297
|
+
if "expires" not in payload:
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
expiry_time = datetime.datetime.fromisoformat(payload["expires"])
|
|
301
|
+
if expiry_time.tzinfo is None:
|
|
302
|
+
# Treat naive datetime as UTC for backward compatibility
|
|
303
|
+
expiry_time = expiry_time.replace(tzinfo=datetime.timezone.utc)
|
|
304
|
+
|
|
305
|
+
return datetime.datetime.now(datetime.timezone.utc) <= expiry_time
|
|
306
|
+
|
|
307
|
+
except Exception: # noqa: BLE001 – we intentionally catch everything here
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
# ------------------------------------------------------------------
|
|
311
|
+
# Alias for backward compatibility
|
|
312
|
+
# ------------------------------------------------------------------
|
|
313
|
+
def valid(self, token: str) -> bool:
|
|
314
|
+
"""
|
|
315
|
+
Alias of :meth:`validate`. Kept for older codebases.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
token (str): Bearer token.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
bool: ``True`` if token is valid.
|
|
322
|
+
"""
|
|
323
|
+
return self.validate(token)
|