tina4-python 0.2.201__tar.gz → 0.2.202__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.
Files changed (89) hide show
  1. {tina4_python-0.2.201 → tina4_python-0.2.202}/PKG-INFO +19 -3
  2. {tina4_python-0.2.201 → tina4_python-0.2.202}/pyproject.toml +18 -4
  3. tina4_python-0.2.202/tina4_python/Auth.py +236 -0
  4. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Messages.py +1 -0
  5. tina4_python-0.2.201/tina4_python/Auth.py +0 -354
  6. {tina4_python-0.2.201 → tina4_python-0.2.202}/.gitignore +0 -0
  7. {tina4_python-0.2.201 → tina4_python-0.2.202}/README.md +0 -0
  8. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Api.py +0 -0
  9. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/CLAUDE.md +0 -0
  10. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/CRUD.py +0 -0
  11. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Constant.py +0 -0
  12. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Database.py +0 -0
  13. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/DatabaseResult.py +0 -0
  14. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/DatabaseTypes.py +0 -0
  15. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Debug.py +0 -0
  16. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/DevReload.py +0 -0
  17. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Env.py +0 -0
  18. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/FieldTypes.py +0 -0
  19. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/GraphQL.py +0 -0
  20. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/HtmlElement.py +0 -0
  21. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Localization.py +0 -0
  22. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/MiddleWare.py +0 -0
  23. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Migration.py +0 -0
  24. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/ORM.py +0 -0
  25. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Queue.py +0 -0
  26. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Request.py +0 -0
  27. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Response.py +0 -0
  28. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Router.py +0 -0
  29. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/SQLToMongo.py +0 -0
  30. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Seeder.py +0 -0
  31. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Session.py +0 -0
  32. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/ShellColors.py +0 -0
  33. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Swagger.py +0 -0
  34. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Template.py +0 -0
  35. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Testing.py +0 -0
  36. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/WSDL.py +0 -0
  37. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Webserver.py +0 -0
  38. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/Websocket.py +0 -0
  39. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/__init__.py +0 -0
  40. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/cli.py +0 -0
  41. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/messages.pot +0 -0
  42. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/public/css/readme.md +0 -0
  43. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/public/css/tina4.css +0 -0
  44. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/public/css/tina4.min.css +0 -0
  45. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/public/favicon.ico +0 -0
  46. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/public/images/403.png +0 -0
  47. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/public/images/404.png +0 -0
  48. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/public/images/500.png +0 -0
  49. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/public/images/logo.png +0 -0
  50. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/public/images/readme.md +0 -0
  51. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/public/js/readme.md +0 -0
  52. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/public/js/reconnecting-websocket.js +0 -0
  53. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/public/js/tina4.js +0 -0
  54. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/public/js/tina4helper.js +0 -0
  55. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/public/swagger/index.html +0 -0
  56. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  57. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  58. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/scss/tina4css/_badges.scss +0 -0
  59. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  60. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/scss/tina4css/_cards.scss +0 -0
  61. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/scss/tina4css/_forms.scss +0 -0
  62. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/scss/tina4css/_grid.scss +0 -0
  63. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/scss/tina4css/_modals.scss +0 -0
  64. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/scss/tina4css/_nav.scss +0 -0
  65. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/scss/tina4css/_reset.scss +0 -0
  66. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/scss/tina4css/_tables.scss +0 -0
  67. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/scss/tina4css/_typography.scss +0 -0
  68. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  69. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/scss/tina4css/_variables.scss +0 -0
  70. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/scss/tina4css/base.scss +0 -0
  71. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/scss/tina4css/colors.scss +0 -0
  72. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/scss/tina4css/tina4.scss +0 -0
  73. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/templates/components/crud.twig +0 -0
  74. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/templates/errors/403.twig +0 -0
  75. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/templates/errors/404.twig +0 -0
  76. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/templates/errors/500.twig +0 -0
  77. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/templates/readme.md +0 -0
  78. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  79. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  80. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  81. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  82. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  83. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  84. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  85. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  86. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  87. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  88. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  89. {tina4_python-0.2.201 → tina4_python-0.2.202}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
@@ -1,15 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 0.2.201
3
+ Version: 0.2.202
4
4
  Summary: Tina4Python - This is not another framework for Python
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  Requires-Python: <4.0,>=3.12
7
7
  Requires-Dist: asyncer>=0.0.8
8
8
  Requires-Dist: bcrypt<5.0.0,>=4.2.1
9
- Requires-Dist: cryptography<45.0.0,>=44.0.0
10
9
  Requires-Dist: hypercorn>=0.18.0
11
10
  Requires-Dist: jinja2<4.0.0,>=3.1.5
12
- Requires-Dist: jurigged>=0.6.0
13
11
  Requires-Dist: libsass<0.24.0,>=0.23.0
14
12
  Requires-Dist: litequeue<0.10,>=0.9
15
13
  Requires-Dist: pyjwt<3.0.0,>=2.10.1
@@ -17,6 +15,24 @@ Requires-Dist: python-dotenv<2.0.0,>=1.0.1
17
15
  Requires-Dist: requests>=2.32.5
18
16
  Requires-Dist: simple-websocket<2.0.0,>=1.1.0
19
17
  Requires-Dist: watchdog<7.0.0,>=6.0.0
18
+ Provides-Extra: all-db
19
+ Requires-Dist: firebird-driver>=1.10.0; extra == 'all-db'
20
+ Requires-Dist: mysql-connector-python>=9.3.0; extra == 'all-db'
21
+ Requires-Dist: psycopg2-binary>=2.9.10; extra == 'all-db'
22
+ Requires-Dist: pymongo>=4.0.0; extra == 'all-db'
23
+ Requires-Dist: pymssql>=2.3.0; extra == 'all-db'
24
+ Provides-Extra: dev-reload
25
+ Requires-Dist: jurigged>=0.6.0; extra == 'dev-reload'
26
+ Provides-Extra: firebird
27
+ Requires-Dist: firebird-driver>=1.10.0; extra == 'firebird'
28
+ Provides-Extra: mongo
29
+ Requires-Dist: pymongo>=4.0.0; extra == 'mongo'
30
+ Provides-Extra: mssql
31
+ Requires-Dist: pymssql>=2.3.0; extra == 'mssql'
32
+ Provides-Extra: mysql
33
+ Requires-Dist: mysql-connector-python>=9.3.0; extra == 'mysql'
34
+ Provides-Extra: postgres
35
+ Requires-Dist: psycopg2-binary>=2.9.10; extra == 'postgres'
20
36
  Description-Content-Type: text/markdown
21
37
 
22
38
  # Tina4 Python — This is not a framework
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "0.2.201"
3
+ version = "0.2.202"
4
4
  description = "Tina4Python - This is not another framework for Python"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam",email = "andrevanzuydam@gmail.com"}
@@ -11,8 +11,7 @@ dependencies = [
11
11
  "jinja2>=3.1.5,<4.0.0",
12
12
  "libsass (>=0.23.0,<0.24.0)",
13
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)",
14
+ "pyjwt>=2.10.1,<3.0.0",
16
15
  "watchdog (>=6.0.0,<7.0.0)",
17
16
  "bcrypt (>=4.2.1,<5.0.0)",
18
17
  "litequeue (>=0.9,<0.10)",
@@ -20,11 +19,26 @@ dependencies = [
20
19
  "asyncer>=0.0.8",
21
20
  "hypercorn>=0.18.0",
22
21
  "requests>=2.32.5",
23
- "jurigged>=0.6.0",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ dev-reload = ["jurigged>=0.6.0"]
26
+ postgres = ["psycopg2-binary>=2.9.10"]
27
+ mysql = ["mysql-connector-python>=9.3.0"]
28
+ mssql = ["pymssql>=2.3.0"]
29
+ firebird = ["firebird-driver>=1.10.0"]
30
+ mongo = ["pymongo>=4.0.0"]
31
+ all-db = [
32
+ "psycopg2-binary>=2.9.10",
33
+ "mysql-connector-python>=9.3.0",
34
+ "pymssql>=2.3.0",
35
+ "firebird-driver>=1.10.0",
36
+ "pymongo>=4.0.0",
24
37
  ]
25
38
 
26
39
  [dependency-groups]
27
40
  dev = [
41
+ "jurigged>=0.6.0",
28
42
  "flake8>=7.2.0",
29
43
  "mkdocs>=1.6.1",
30
44
  "mysql-connector-python>=9.3.0",
@@ -0,0 +1,236 @@
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
+ """JWT authentication and password hashing for Tina4.
8
+
9
+ Provides the ``Auth`` class which handles:
10
+ - HS256 JWT token creation and validation using the ``SECRET`` env var
11
+ - Configurable token expiry (default 2 minutes, override via
12
+ ``TINA4_TOKEN_LIMIT`` environment variable)
13
+ - Password hashing and verification using bcrypt
14
+ - Payload extraction from valid or expired tokens
15
+
16
+ The framework creates a global ``tina4_python.tina4_auth`` instance at
17
+ startup. Sessions, secured routes, and middleware all delegate to this
18
+ instance for token operations.
19
+
20
+ Example::
21
+
22
+ from tina4_python import tina4_auth
23
+
24
+ token = tina4_auth.get_token({"user_id": 42})
25
+ is_valid = tina4_auth.valid(token)
26
+ payload = tina4_auth.get_payload(token)
27
+ hashed = tina4_auth.get_password("secret")
28
+ ok = tina4_auth.check_password("secret", hashed)
29
+ """
30
+
31
+ __all__ = ["Auth", "AuthJSONSerializer"]
32
+
33
+ import datetime
34
+ import os
35
+ import jwt
36
+ import bcrypt
37
+ from json import JSONEncoder
38
+
39
+
40
+ class AuthJSONSerializer(JSONEncoder):
41
+ """
42
+ Custom JSON encoder used by PyJWT when serializing payload objects.
43
+
44
+ Ensures that ``datetime.datetime`` instances are correctly converted to ISO-8601
45
+ strings so they can be embedded in JWT claims.
46
+ """
47
+
48
+ def default(self, o):
49
+ if isinstance(o, datetime.datetime):
50
+ return o.isoformat()
51
+ return super().default(o)
52
+
53
+
54
+ class Auth:
55
+ """
56
+ Authentication & authorization helper for Tina4 projects.
57
+
58
+ Handles:
59
+ - Password hashing/verification (bcrypt)
60
+ - Signing JWT tokens with HS256 (SECRET env var)
61
+ - Verifying JWT tokens with HS256
62
+ - Simple API-KEY fallback validation
63
+
64
+ Environment variables used:
65
+ - ``SECRET`` → HMAC-SHA256 signing secret for JWT
66
+ - ``API_KEY`` → optional static API key (checked before JWT)
67
+ - ``TINA4_TOKEN_LIMIT`` → default token lifetime in minutes (default: 2)
68
+ """
69
+
70
+ secret: str | None = None
71
+ root_path: str = None
72
+
73
+ # ------------------------------------------------------------------
74
+ # Password handling (bcrypt)
75
+ # ------------------------------------------------------------------
76
+ def hash_password(self, text: str) -> str:
77
+ """
78
+ Generate a bcrypt hash for the given plain-text password.
79
+
80
+ Args:
81
+ text (str): Plain-text password.
82
+
83
+ Returns:
84
+ str: bcrypt hash (as UTF-8 string). Safe to store in a database.
85
+ """
86
+ password_bytes = text.encode("utf-8")
87
+ salt = bcrypt.gensalt()
88
+ return bcrypt.hashpw(password_bytes, salt).decode("utf-8")
89
+
90
+ def check_password(self, password_hash: str, text: str) -> bool:
91
+ """
92
+ Verify a plain-text password against a previously created bcrypt hash.
93
+
94
+ Args:
95
+ password_hash (str): Hash returned by :meth:`hash_password`.
96
+ text (str): Plain-text password to verify.
97
+
98
+ Returns:
99
+ bool: ``True`` if the password matches the hash.
100
+ """
101
+ password_bytes = text.encode("utf-8")
102
+ return bcrypt.checkpw(password_bytes, password_hash.encode("utf-8"))
103
+
104
+ # ------------------------------------------------------------------
105
+ # Constructor
106
+ # ------------------------------------------------------------------
107
+ def __init__(self, root_path: str):
108
+ """
109
+ Initialise the Auth helper.
110
+
111
+ Detects and removes legacy RS256 key files from older Tina4 installs,
112
+ printing a one-time migration notice when found.
113
+
114
+ Args:
115
+ root_path (str): Absolute path to the project root.
116
+ """
117
+ from tina4_python.Debug import Debug
118
+ from tina4_python import Messages
119
+
120
+ self.root_path = root_path
121
+ self.secret = os.environ.get("SECRET", "{self.secret}")
122
+ if self.secret == "{self.secret}":
123
+ Debug.warning(Messages.MSG_AUTH_NO_SECRET)
124
+
125
+ # ------------------------------------------------------------------
126
+ # One-time RS256 → HS256 migration: remove old key files if present
127
+ # ------------------------------------------------------------------
128
+ secrets_dir = os.path.join(root_path, "secrets")
129
+ legacy_files = [
130
+ os.path.join(secrets_dir, "private.key"),
131
+ os.path.join(secrets_dir, "public.key"),
132
+ os.path.join(secrets_dir, "domain.cert"),
133
+ ]
134
+ if any(os.path.isfile(f) for f in legacy_files):
135
+ for f in legacy_files:
136
+ if os.path.isfile(f):
137
+ os.remove(f)
138
+ Debug.warning(Messages.MSG_AUTH_RS256_MIGRATION)
139
+
140
+ # ------------------------------------------------------------------
141
+ # JWT creation & verification (HS256)
142
+ # ------------------------------------------------------------------
143
+ def get_token(self, payload_data: dict, expiry_minutes: int = 0) -> str:
144
+ """
145
+ Create a signed JWT (HS256) containing the supplied payload.
146
+
147
+ If ``expires`` is not present in ``payload_data`` an expiration claim
148
+ will be added automatically using ``TINA4_TOKEN_LIMIT`` (default 2 minutes)
149
+ or the value supplied via ``expiry_minutes``.
150
+
151
+ Args:
152
+ payload_data (dict): Claims to embed in the token.
153
+ expiry_minutes (int): Override default token lifetime (0 = use env default).
154
+
155
+ Returns:
156
+ str: Signed JWT (compact serialization).
157
+ """
158
+ now = datetime.datetime.now(datetime.timezone.utc)
159
+
160
+ if "expires" not in payload_data:
161
+ token_limit_minutes = int(os.environ.get("TINA4_TOKEN_LIMIT", 2))
162
+ if expiry_minutes != 0:
163
+ token_limit_minutes = expiry_minutes
164
+ expiry_time = now + datetime.timedelta(minutes=token_limit_minutes)
165
+ payload_data["expires"] = expiry_time.isoformat()
166
+
167
+ token = jwt.encode(
168
+ payload=payload_data,
169
+ key=self.secret,
170
+ algorithm="HS256",
171
+ json_encoder=AuthJSONSerializer,
172
+ )
173
+ return token
174
+
175
+ def get_payload(self, token: str) -> dict | None:
176
+ """
177
+ Decode a JWT and return its payload (without verification of expiry).
178
+
179
+ Args:
180
+ token (str): JWT to decode.
181
+
182
+ Returns:
183
+ dict | None: Payload dictionary or ``None`` on invalid signature.
184
+ """
185
+ try:
186
+ payload = jwt.decode(
187
+ token,
188
+ key=self.secret,
189
+ algorithms=["HS256"],
190
+ options={"verify_exp": False},
191
+ )
192
+ return payload
193
+ except Exception:
194
+ return None
195
+
196
+ def validate(self, token: str) -> bool:
197
+ """
198
+ Full token validation.
199
+
200
+ Checks:
201
+ 1. Optional static ``API_KEY`` environment variable (quick bypass)
202
+ 2. HS256 signature using SECRET
203
+ 3. Presence and validity of the ``expires`` claim
204
+
205
+ Args:
206
+ token (str): Bearer token.
207
+
208
+ Returns:
209
+ bool: ``True`` if the token is valid and not expired.
210
+ """
211
+ if os.environ.get("API_KEY") and token == os.environ.get("API_KEY"):
212
+ return True
213
+
214
+ try:
215
+ payload = jwt.decode(
216
+ token,
217
+ key=self.secret,
218
+ algorithms=["HS256"],
219
+ options={"verify_exp": False},
220
+ )
221
+
222
+ if "expires" not in payload:
223
+ return False
224
+
225
+ expiry_time = datetime.datetime.fromisoformat(payload["expires"])
226
+ if expiry_time.tzinfo is None:
227
+ expiry_time = expiry_time.replace(tzinfo=datetime.timezone.utc)
228
+
229
+ return datetime.datetime.now(datetime.timezone.utc) <= expiry_time
230
+
231
+ except Exception: # noqa: BLE001
232
+ return False
233
+
234
+ def valid(self, token: str) -> bool:
235
+ """Alias of :meth:`validate`. Kept for backward compatibility."""
236
+ return self.validate(token)
@@ -60,6 +60,7 @@ MSG_DB_UNIMPLEMENTED = _('Please implement {driver} in Database.py and make a pu
60
60
 
61
61
  # --- Auth messages ---
62
62
  MSG_AUTH_NO_SECRET = _('No SECRET env var set - using default secret. Set SECRET in your .env for production.')
63
+ MSG_AUTH_RS256_MIGRATION = _('JWT upgraded RS256 → HS256: old key files removed, all existing tokens invalidated. This is a one-time migration.')
63
64
 
64
65
  # --- WebSocket messages ---
65
66
  MSG_WS_CREATE_ERROR = _('Error creating Websocket, perhaps you need to install simple_websocket ?')
@@ -1,354 +0,0 @@
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
- """JWT authentication and password hashing for Tina4.
8
-
9
- Provides the ``Auth`` class which handles:
10
- - RS256 JWT token creation and validation using auto-generated
11
- self-signed certificates (stored in the ``cert/`` directory)
12
- - Configurable token expiry (default 24 hours, override via
13
- ``TINA4_TOKEN_EXPIRES_IN`` environment variable)
14
- - Password hashing and verification using bcrypt
15
- - Payload extraction from valid or expired tokens
16
-
17
- The framework creates a global ``tina4_python.tina4_auth`` instance at
18
- startup. Sessions, secured routes, and middleware all delegate to this
19
- instance for token operations.
20
-
21
- Example::
22
-
23
- from tina4_python import tina4_auth
24
-
25
- token = tina4_auth.get_token({"user_id": 42})
26
- is_valid = tina4_auth.valid(token)
27
- payload = tina4_auth.get_payload(token)
28
- hashed = tina4_auth.get_password("secret")
29
- ok = tina4_auth.check_password("secret", hashed)
30
- """
31
-
32
- __all__ = ["Auth", "AuthJSONSerializer"]
33
-
34
- import datetime
35
- import os
36
- import jwt
37
- import bcrypt
38
- from json import JSONEncoder
39
- from cryptography import x509
40
- from cryptography.x509 import NameOID
41
- from cryptography.hazmat.primitives import serialization, hashes
42
- from cryptography.hazmat.primitives.asymmetric import rsa
43
- from cryptography.hazmat.backends import default_backend
44
-
45
- class AuthJSONSerializer(JSONEncoder):
46
- """
47
- Custom JSON encoder used by PyJWT when serializing payload objects.
48
-
49
- Ensures that ``datetime.datetime`` instances are correctly converted to ISO-8601
50
- strings so they can be embedded in JWT claims.
51
- """
52
-
53
- def default(self, o):
54
- if isinstance(o, datetime.datetime):
55
- return o.isoformat()
56
- return super().default(o)
57
-
58
-
59
- class Auth:
60
- """
61
- Authentication & authorization helper for Tina4 projects.
62
-
63
- Handles:
64
- - Password hashing/verification (bcrypt)
65
- - Automatic creation and loading of an RSA private/public key pair
66
- - Self-signed certificate generation for local HTTPS development
67
- - Signing JWT tokens with RS256 (private key)
68
- - Verifying JWT tokens with RS256 (public key)
69
- - Simple API-KEY fallback validation
70
-
71
- Keys and certificates are stored in ``<root_path>/secrets/``:
72
- - private.key → encrypted PEM private key
73
- - public.key → PEM public key (unencrypted)
74
- - domain.cert → self-signed certificate (for local dev servers)
75
-
76
- Environment variables used:
77
- - ``SECRET`` → passphrase used to encrypt the private key
78
- - ``API_KEY`` → optional static API key (checked before JWT)
79
- - ``TINA4_TOKEN_LIMIT`` → default token lifetime in minutes (default: 2)
80
- - Country/State/City/Organization/Domain variables for the cert
81
- """
82
-
83
- # ------------------------------------------------------------------
84
- # Class-level attributes (set in __init__)
85
- # ------------------------------------------------------------------
86
- secret: str | None = None # Passphrase for private key encryption
87
- private_key: str = None # Path to encrypted private key file
88
- public_key: str = None # Path to public key file
89
- self_signed: str = None # Path to self-signed cert file
90
- root_path: str = None # Project root directory
91
-
92
- # Cached key objects (avoid reloading from disk on every request)
93
- loaded_private_key = None
94
- loaded_public_key = None
95
-
96
- # ------------------------------------------------------------------
97
- # Password handling (bcrypt)
98
- # ------------------------------------------------------------------
99
- def hash_password(self, text: str) -> str:
100
- """
101
- Generate a bcrypt hash for the given plain-text password.
102
-
103
- Args:
104
- text (str): Plain-text password.
105
-
106
- Returns:
107
- str: bcrypt hash (as UTF-8 string). Safe to store in a database.
108
- """
109
- password_bytes = text.encode("utf-8")
110
- salt = bcrypt.gensalt()
111
- return bcrypt.hashpw(password_bytes, salt).decode("utf-8")
112
-
113
- def check_password(self, password_hash: str, text: str) -> bool:
114
- """
115
- Verify a plain-text password against a previously created bcrypt hash.
116
-
117
- Args:
118
- password_hash (str): Hash returned by :meth:`hash_password`.
119
- text (str): Plain-text password to verify.
120
-
121
- Returns:
122
- bool: ``True`` if the password matches the hash.
123
- """
124
- password_bytes = text.encode("utf-8")
125
- return bcrypt.checkpw(password_bytes, password_hash.encode("utf-8"))
126
-
127
- # ------------------------------------------------------------------
128
- # Private / public key loading (with caching)
129
- # ------------------------------------------------------------------
130
- def load_private_key(self):
131
- """
132
- Load (and cache) the RSA private key from ``secrets/private.key``.
133
-
134
- The key is encrypted with the passphrase stored in ``self.secret``
135
- (which comes from the environment variable ``SECRET``).
136
-
137
- Returns:
138
- cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey
139
- """
140
- if self.loaded_private_key:
141
- return self.loaded_private_key
142
-
143
- with open(self.private_key, "rb") as f:
144
- private_key = serialization.load_pem_private_key(
145
- f.read(),
146
- password=self.secret.encode(),
147
- backend=default_backend(),
148
- )
149
- self.loaded_private_key = private_key
150
- return private_key
151
-
152
- def load_public_key(self):
153
- """
154
- Load (and cache) the RSA public key from ``secrets/public.key``.
155
-
156
- Returns:
157
- cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey
158
- """
159
- if self.loaded_public_key:
160
- return self.loaded_public_key
161
-
162
- with open(self.public_key, "rb") as f:
163
- public_key = serialization.load_pem_public_key(
164
- f.read(), backend=default_backend()
165
- )
166
- self.loaded_public_key = public_key
167
- return public_key
168
-
169
- # ------------------------------------------------------------------
170
- # Constructor – creates secrets folder & keys if missing
171
- # ------------------------------------------------------------------
172
- def __init__(self, root_path: str):
173
- """
174
- Initialise the Auth helper and ensure cryptographic material exists.
175
-
176
- Args:
177
- root_path (str): Absolute path to the project root (where the
178
- ``secrets`` folder will be created).
179
- """
180
- self.root_path = root_path
181
- self.secret = os.environ.get("SECRET", "{self.secret}")
182
- if self.secret == "{self.secret}":
183
- from tina4_python.Debug import Debug
184
- from tina4_python import Messages
185
- Debug.warning(Messages.MSG_AUTH_NO_SECRET)
186
- self.private_key = os.path.join(root_path, "secrets", "private.key")
187
- self.public_key = os.path.join(root_path, "secrets", "public.key")
188
- self.self_signed = os.path.join(root_path, "secrets", "domain.cert")
189
-
190
- # Ensure secrets directory exists
191
- os.makedirs(os.path.join(root_path, "secrets"), exist_ok=True)
192
-
193
- # ------------------------------------------------------------------
194
- # 1. Private key – generate if missing
195
- # ------------------------------------------------------------------
196
- if not os.path.isfile(self.private_key):
197
- private_key = rsa.generate_private_key(
198
- public_exponent=65537,
199
- key_size=2048,
200
- )
201
- with open(self.private_key, "wb") as f:
202
- f.write(private_key.private_bytes(
203
- encoding=serialization.Encoding.PEM,
204
- format=serialization.PrivateFormat.TraditionalOpenSSL,
205
- encryption_algorithm=serialization.BestAvailableEncryption(self.secret.encode()),
206
- ))
207
- else:
208
- private_key = self.load_private_key()
209
-
210
- # ------------------------------------------------------------------
211
- # 2. Public key – derive from private key if missing
212
- # ------------------------------------------------------------------
213
- if not os.path.isfile(self.public_key):
214
- public_key = private_key.public_key()
215
- public_pem = public_key.public_bytes(
216
- encoding=serialization.Encoding.PEM,
217
- format=serialization.PublicFormat.SubjectPublicKeyInfo,
218
- )
219
- with open(self.public_key, "wb") as f:
220
- f.write(public_pem)
221
-
222
- # ------------------------------------------------------------------
223
- # 3. Self-signed certificate (useful for local HTTPS servers)
224
- # ------------------------------------------------------------------
225
- if not os.path.isfile(self.self_signed):
226
- subject = issuer = x509.Name(
227
- [
228
- x509.NameAttribute(NameOID.COUNTRY_NAME, os.environ.get("COUNTRY", "ZA")),
229
- x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, os.environ.get("STATE", "WESTERN CAPE")),
230
- x509.NameAttribute(NameOID.LOCALITY_NAME, os.environ.get("CITY", "CAPE TOWN")),
231
- x509.NameAttribute(NameOID.ORGANIZATION_NAME, os.environ.get("ORGANIZATION", "Tina4")),
232
- x509.NameAttribute(NameOID.COMMON_NAME, os.environ.get("DOMAIN_NAME", "localhost")),
233
- ]
234
- )
235
- cert = (
236
- x509.CertificateBuilder()
237
- .subject_name(subject)
238
- .issuer_name(issuer)
239
- .public_key(private_key.public_key())
240
- .serial_number(x509.random_serial_number())
241
- .not_valid_before(datetime.datetime.now(datetime.timezone.utc))
242
- .not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=99999))
243
- .add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False)
244
- .sign(private_key, hashes.SHA256())
245
- )
246
-
247
- with open(self.self_signed, "wb") as f:
248
- f.write(cert.public_bytes(serialization.Encoding.PEM))
249
-
250
- # ------------------------------------------------------------------
251
- # JWT creation & verification
252
- # ------------------------------------------------------------------
253
- def get_token(self, payload_data: dict, expiry_minutes: int = 0) -> str:
254
- """
255
- Create a signed JWT (RS256) containing the supplied payload.
256
-
257
- If ``expires`` is not present in ``payload_data`` an expiration claim
258
- will be added automatically using ``TINA4_TOKEN_LIMIT`` (default 2 minutes)
259
- or the value supplied via ``expiry_minutes``.
260
-
261
- Args:
262
- payload_data (dict): Claims to embed in the token.
263
- expiry_minutes (int): Override default token lifetime (0 = use env default).
264
-
265
- Returns:
266
- str: Signed JWT (compact serialization).
267
- """
268
- private_key = self.load_private_key()
269
- now = datetime.datetime.now(datetime.timezone.utc)
270
-
271
- if "expires" not in payload_data:
272
- token_limit_minutes = int(os.environ.get("TINA4_TOKEN_LIMIT", 2))
273
- if expiry_minutes != 0:
274
- token_limit_minutes = expiry_minutes
275
- expiry_time = now + datetime.timedelta(minutes=token_limit_minutes)
276
- payload_data["expires"] = expiry_time.isoformat()
277
-
278
- token = jwt.encode(
279
- payload=payload_data,
280
- key=private_key,
281
- algorithm="RS256",
282
- json_encoder=AuthJSONSerializer,
283
- )
284
- return token
285
-
286
- def get_payload(self, token: str) -> dict | None:
287
- """
288
- Decode a JWT and return its payload (without verification of expiry).
289
-
290
- Used when you only need the claims and will perform your own validation.
291
-
292
- Args:
293
- token (str): JWT to decode.
294
-
295
- Returns:
296
- dict | None: Payload dictionary or ``None`` on invalid signature.
297
- """
298
- public_key = self.load_public_key()
299
- try:
300
- payload = jwt.decode(token, key=public_key, algorithms=["RS256"])
301
- return payload
302
- except Exception:
303
- return None
304
-
305
- def validate(self, token: str) -> bool:
306
- """
307
- Full token validation.
308
-
309
- Checks:
310
- 1. Optional static ``API_KEY`` environment variable (quick bypass)
311
- 2. RS256 signature using the public key
312
- 3. Presence and validity of the ``expires`` claim
313
-
314
- Args:
315
- token (str): Bearer token.
316
-
317
- Returns:
318
- bool: ``True`` if the token is valid and not expired.
319
- """
320
- # Simple API-KEY fallback (useful for quick internal scripts)
321
- if os.environ.get("API_KEY") and token == os.environ.get("API_KEY"):
322
- return True
323
-
324
- public_key = self.load_public_key()
325
- try:
326
- payload = jwt.decode(token, key=public_key, algorithms=["RS256"])
327
-
328
- if "expires" not in payload:
329
- return False
330
-
331
- expiry_time = datetime.datetime.fromisoformat(payload["expires"])
332
- if expiry_time.tzinfo is None:
333
- # Treat naive datetime as UTC for backward compatibility
334
- expiry_time = expiry_time.replace(tzinfo=datetime.timezone.utc)
335
-
336
- return datetime.datetime.now(datetime.timezone.utc) <= expiry_time
337
-
338
- except Exception: # noqa: BLE001 – we intentionally catch everything here
339
- return False
340
-
341
- # ------------------------------------------------------------------
342
- # Alias for backward compatibility
343
- # ------------------------------------------------------------------
344
- def valid(self, token: str) -> bool:
345
- """
346
- Alias of :meth:`validate`. Kept for older codebases.
347
-
348
- Args:
349
- token (str): Bearer token.
350
-
351
- Returns:
352
- bool: ``True`` if token is valid.
353
- """
354
- return self.validate(token)
File without changes