keepup-admin 0.1.0__py3-none-any.whl
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.
- keepup/THIRD-PARTY.md +44 -0
- keepup/__init__.py +41 -0
- keepup/api_versions.py +100 -0
- keepup/audit.py +499 -0
- keepup/auth/__init__.py +7 -0
- keepup/auth/config.py +128 -0
- keepup/auth/dependencies.py +485 -0
- keepup/auth/dto/__init__.py +0 -0
- keepup/auth/dto/token.py +31 -0
- keepup/auth/factory.py +28 -0
- keepup/auth/login_throttle.py +151 -0
- keepup/auth/oidc.py +245 -0
- keepup/auth/oidc_policy.py +111 -0
- keepup/auth/oidc_routes.py +288 -0
- keepup/auth/panel_session.py +220 -0
- keepup/auth/permissions.py +59 -0
- keepup/auth/providers/__init__.py +0 -0
- keepup/auth/providers/base.py +168 -0
- keepup/auth/providers/local.py +180 -0
- keepup/auth/routes.py +660 -0
- keepup/auth/seed_accounts.py +322 -0
- keepup/auth/session_lifetime.py +104 -0
- keepup/auth/signing_key.py +138 -0
- keepup/auth/websocket.py +86 -0
- keepup/cluster.py +634 -0
- keepup/db.py +663 -0
- keepup/events.py +764 -0
- keepup/factory.py +484 -0
- keepup/instance.py +46 -0
- keepup/integrations.py +260 -0
- keepup/locks.py +412 -0
- keepup/logging_setup.py +690 -0
- keepup/metrics.py +818 -0
- keepup/metrics_retention.py +376 -0
- keepup/modules.py +572 -0
- keepup/notification_bus.py +355 -0
- keepup/plugins/__init__.py +9 -0
- keepup/plugins/admin.py +246 -0
- keepup/plugins/base.py +234 -0
- keepup/plugins/enablement.py +184 -0
- keepup/plugins/registry.py +171 -0
- keepup/plugins/route_mask.py +338 -0
- keepup/plugins/routes.py +376 -0
- keepup/roles.py +17 -0
- keepup/scheduler.py +93 -0
- keepup/schema.py +295 -0
- keepup/sections.json +104 -0
- keepup/settings.py +184 -0
- keepup/static/css/aos.css +1 -0
- keepup/static/css/main_nebula.css +232 -0
- keepup/static/css/main_new.css +852 -0
- keepup/static/css/tailwind.css +1 -0
- keepup/static/index_nebula.html +293 -0
- keepup/static/index_new.html +286 -0
- keepup/static/js/aos.js +1 -0
- keepup/static/js/feather-icons.js +13 -0
- keepup/static/js/main_new.js +1861 -0
- keepup/static/js/tailwind.js +83 -0
- keepup/static/modules/css/background_tasks.css +233 -0
- keepup/static/modules/css/cluster.css +16 -0
- keepup/static/modules/css/event_manager.css +386 -0
- keepup/static/modules/css/integration_logs.css +33 -0
- keepup/static/modules/css/metrics.css +115 -0
- keepup/static/modules/css/modules.css +189 -0
- keepup/static/modules/css/themes.css +563 -0
- keepup/static/modules/css/users.css +278 -0
- keepup/static/modules/js/background_tasks.js +657 -0
- keepup/static/modules/js/chart.js +14 -0
- keepup/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
- keepup/static/modules/js/cluster.js +363 -0
- keepup/static/modules/js/event_manager.js +979 -0
- keepup/static/modules/js/integration_logs.js +767 -0
- keepup/static/modules/js/metrics.js +908 -0
- keepup/static/modules/js/modules.js +1086 -0
- keepup/static/modules/js/themes.js +653 -0
- keepup/static/modules/js/users.js +784 -0
- keepup/tables.py +302 -0
- keepup/themes.py +496 -0
- keepup/web.py +182 -0
- keepup_admin-0.1.0.dist-info/METADATA +117 -0
- keepup_admin-0.1.0.dist-info/RECORD +86 -0
- keepup_admin-0.1.0.dist-info/WHEEL +5 -0
- keepup_admin-0.1.0.dist-info/licenses/LICENSE +202 -0
- keepup_admin-0.1.0.dist-info/licenses/NOTICE +22 -0
- keepup_admin-0.1.0.dist-info/licenses/THIRD-PARTY.md +44 -0
- keepup_admin-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""The accounts an application creates for itself on the first start.
|
|
2
|
+
|
|
3
|
+
There are two. `admin` is a person's first way in: without it nobody can sign
|
|
4
|
+
into a fresh database and there would be nothing to bring the panel up with.
|
|
5
|
+
`system` is not a person but a signature: background jobs record their work
|
|
6
|
+
under somebody's name and need a row in `users`.
|
|
7
|
+
|
|
8
|
+
Both used to be created with a password typed into the source: `admin123` for
|
|
9
|
+
the first and `system_internal_use_only_123` for the second, while two other
|
|
10
|
+
places that created the same `system` also typed `system_password`. A password
|
|
11
|
+
in a repository is not a password: it is known to everyone who has seen the
|
|
12
|
+
code, it is the same on every deployment, and it does not change because the
|
|
13
|
+
deployment became reachable from outside. `admin123` is on top of that in the
|
|
14
|
+
breach lists, and the browser warns about it at every sign-in -- truthfully.
|
|
15
|
+
|
|
16
|
+
Three decisions follow.
|
|
17
|
+
|
|
18
|
+
**The administrator's password is set by whoever deploys, not by the code.** It
|
|
19
|
+
is read from `ADMIN_INITIAL_PASSWORD`; without that variable it is generated at
|
|
20
|
+
random and printed once into the start-up log -- that is exactly where it can
|
|
21
|
+
be read, and it differs on every deployment. A default fit for everyone does
|
|
22
|
+
not exist here.
|
|
23
|
+
|
|
24
|
+
**`system` has no password at all.** Nobody signs in as it, so it is given a
|
|
25
|
+
random value known to no one, ourselves included, and a status that sign-in
|
|
26
|
+
rejects. The account stays a signature and stops being a way in.
|
|
27
|
+
|
|
28
|
+
**One place creates the accounts.** Three implementations of the same thing had
|
|
29
|
+
already drifted apart in password, role and even in what to write into
|
|
30
|
+
`status`; when one of them is fixed, the others go on creating the hole.
|
|
31
|
+
|
|
32
|
+
Existing databases are not healed by this on their own: `system` is already
|
|
33
|
+
there, and the creating code does not look at it. So a known password on an
|
|
34
|
+
existing row is retired at start-up -- see `retire_known_system_password`.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import logging
|
|
40
|
+
import os
|
|
41
|
+
import secrets
|
|
42
|
+
from typing import Iterable, Mapping, Optional, Sequence, Tuple
|
|
43
|
+
|
|
44
|
+
import bcrypt
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
#: The first administrator's password, when whoever deploys wants to set it.
|
|
49
|
+
ADMIN_PASSWORD_ENV = "ADMIN_INITIAL_PASSWORD"
|
|
50
|
+
|
|
51
|
+
ADMIN_USERNAME = "admin"
|
|
52
|
+
SYSTEM_USERNAME = "system"
|
|
53
|
+
|
|
54
|
+
#: The status sign-in refuses: it admits `active` only
|
|
55
|
+
#: (`keepup/auth/routes.py`). The same trick is already used for the platform's
|
|
56
|
+
#: own account in token settlement.
|
|
57
|
+
SYSTEM_STATUS = "system"
|
|
58
|
+
|
|
59
|
+
#: The passwords earlier builds handed `system` -- all three, because there
|
|
60
|
+
#: were three places creating it. An existing row holding any of them stops
|
|
61
|
+
#: being a way in at the next start.
|
|
62
|
+
RETIRED_SYSTEM_PASSWORDS: Tuple[str, ...] = (
|
|
63
|
+
"system_internal_use_only_123",
|
|
64
|
+
"system_password",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
#: The same for the administrator. Its password is not overwritten silently --
|
|
68
|
+
#: somebody may be working with the deployment right now -- but it is named at
|
|
69
|
+
#: every start.
|
|
70
|
+
RETIRED_ADMIN_PASSWORDS: Tuple[str, ...] = ("admin123",)
|
|
71
|
+
|
|
72
|
+
#: Length of a generated password, in bytes of randomness before encoding.
|
|
73
|
+
GENERATED_PASSWORD_BYTES = 18
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --- decisions that can be checked without a database ----------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def generated_password(nbytes: int = GENERATED_PASSWORD_BYTES) -> str:
|
|
80
|
+
"""A random password fit both for typing by hand and for pasting.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
nbytes: bytes of randomness before encoding.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The password.
|
|
87
|
+
"""
|
|
88
|
+
return secrets.token_urlsafe(nbytes)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def initial_admin_password(environ: Optional[Mapping[str, str]] = None) -> Tuple[str, bool]:
|
|
92
|
+
"""The first administrator's password, and whether it was generated.
|
|
93
|
+
|
|
94
|
+
An empty variable is not a password that was set, it is a variable that was
|
|
95
|
+
forgotten: an empty password cannot be signed in with, and accepting it
|
|
96
|
+
silently would mean creating an account nobody can use.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
environ: the environment to read; the process environment when omitted.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
A pair (password, generated), where generated says it has to be
|
|
103
|
+
announced because nobody else knows it.
|
|
104
|
+
"""
|
|
105
|
+
if environ is None:
|
|
106
|
+
environ = os.environ
|
|
107
|
+
given = (environ.get(ADMIN_PASSWORD_ENV) or "").strip()
|
|
108
|
+
if given:
|
|
109
|
+
return given, False
|
|
110
|
+
return generated_password(), True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def unusable_password() -> str:
|
|
114
|
+
"""A password nobody knows: it does not leave this function.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
The value to store as a hash for an account that must not be a way in.
|
|
118
|
+
"""
|
|
119
|
+
return secrets.token_urlsafe(32)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def hash_password(password: str) -> str:
|
|
123
|
+
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def matches_any(password_hash: Optional[str], candidates: Iterable[str]) -> bool:
|
|
127
|
+
"""Whether the hash matches one of the known retired passwords.
|
|
128
|
+
|
|
129
|
+
A hash that cannot be read (empty, from another scheme, a marker left by an
|
|
130
|
+
external provider) is not a match but a case that is not ours: the answer
|
|
131
|
+
is a silent no, because the only action here is retiring a known password,
|
|
132
|
+
and retiring somebody else's scheme is not ours to do.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
password_hash: the stored hash, whatever it holds.
|
|
136
|
+
candidates: the retired passwords to check against.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
True when one of them matches.
|
|
140
|
+
"""
|
|
141
|
+
if not password_hash:
|
|
142
|
+
return False
|
|
143
|
+
for candidate in candidates:
|
|
144
|
+
try:
|
|
145
|
+
if bcrypt.checkpw(candidate.encode("utf-8"), password_hash.encode("utf-8")):
|
|
146
|
+
return True
|
|
147
|
+
except (ValueError, TypeError):
|
|
148
|
+
return False
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def announce_generated_admin_password(password: str, log=logger) -> None:
|
|
153
|
+
"""The only place a generated password is shown to a person.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
password: the generated password.
|
|
157
|
+
log: the logger to announce through.
|
|
158
|
+
"""
|
|
159
|
+
log.warning(
|
|
160
|
+
"Created the administrator account '%s' with a generated password: %s\n"
|
|
161
|
+
"It is stored nowhere else and will not be shown again. Sign in and "
|
|
162
|
+
"change it, or set %s before the first start.",
|
|
163
|
+
ADMIN_USERNAME, password, ADMIN_PASSWORD_ENV,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def warn_about_retired_admin_password(log=logger) -> None:
|
|
168
|
+
"""Say out loud what would otherwise be learned from the browser.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
log: the logger to warn through.
|
|
172
|
+
"""
|
|
173
|
+
log.warning(
|
|
174
|
+
"Account '%s' still carries a password from earlier builds. It is the "
|
|
175
|
+
"same on every deployment, known to everyone who has seen the source, "
|
|
176
|
+
"and is in the breach lists. Change it in the panel: the Users section "
|
|
177
|
+
"-> Change password.",
|
|
178
|
+
ADMIN_USERNAME,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# --- what is written to the database ---------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _sql(query: str, is_postgres: bool) -> str:
|
|
186
|
+
"""Queries are written with `?`; PostgreSQL gets `%s`, as elsewhere in the schema.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
query: the query as written.
|
|
190
|
+
is_postgres: whether the connection is PostgreSQL.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
The query in the dialect of that connection.
|
|
194
|
+
"""
|
|
195
|
+
return query.replace("?", "%s") if is_postgres else query
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _scalar(row) -> int:
|
|
199
|
+
"""The row's first column, however the cursor returns the row.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
row: a row, as a mapping or a sequence, or nothing.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
The value, or 0 when there is no row.
|
|
206
|
+
"""
|
|
207
|
+
if not row:
|
|
208
|
+
return 0
|
|
209
|
+
if hasattr(row, "get") and callable(getattr(row, "get")):
|
|
210
|
+
return row.get("count", 0) or 0
|
|
211
|
+
return row[0] if len(row) > 0 else 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _exists(cursor, username: str, is_postgres: bool) -> bool:
|
|
215
|
+
cursor.execute(_sql("SELECT COUNT(*) as count FROM users WHERE username = ?", is_postgres),
|
|
216
|
+
(username,))
|
|
217
|
+
return _scalar(cursor.fetchone()) > 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _insert(cursor, *, username: str, password_hash: str, status: str, role: str,
|
|
221
|
+
auth_source: str, is_postgres: bool) -> None:
|
|
222
|
+
cursor.execute(
|
|
223
|
+
_sql("INSERT INTO users (username, password_hash, status, role, auth_source) "
|
|
224
|
+
"VALUES (?, ?, ?, ?, ?)", is_postgres),
|
|
225
|
+
(username, password_hash, status, role, auth_source),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def ensure_admin(cursor, *, role: str, auth_source: str, is_postgres: bool,
|
|
230
|
+
environ: Optional[Mapping[str, str]] = None) -> None:
|
|
231
|
+
"""Create the administrator when absent; otherwise check its password.
|
|
232
|
+
|
|
233
|
+
An existing password is left alone: somebody may be working with the
|
|
234
|
+
deployment right now, and changing it from a background procedure would cut
|
|
235
|
+
a person off from their own panel.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
cursor: an open cursor on the users table.
|
|
239
|
+
role: the role to give a newly created account.
|
|
240
|
+
auth_source: what to record as the account's origin.
|
|
241
|
+
is_postgres: whether the connection is PostgreSQL.
|
|
242
|
+
environ: the environment to read the password from.
|
|
243
|
+
"""
|
|
244
|
+
if not _exists(cursor, ADMIN_USERNAME, is_postgres):
|
|
245
|
+
password, generated = initial_admin_password(environ)
|
|
246
|
+
_insert(cursor, username=ADMIN_USERNAME, password_hash=hash_password(password),
|
|
247
|
+
status="active", role=role, auth_source=auth_source,
|
|
248
|
+
is_postgres=is_postgres)
|
|
249
|
+
if generated:
|
|
250
|
+
announce_generated_admin_password(password)
|
|
251
|
+
else:
|
|
252
|
+
logger.info("Created the administrator account '%s' with the given password",
|
|
253
|
+
ADMIN_USERNAME)
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
cursor.execute(_sql("SELECT password_hash FROM users WHERE username = ?", is_postgres),
|
|
257
|
+
(ADMIN_USERNAME,))
|
|
258
|
+
row = cursor.fetchone()
|
|
259
|
+
stored = row.get("password_hash") if hasattr(row, "get") else (row[0] if row else None)
|
|
260
|
+
if matches_any(stored, RETIRED_ADMIN_PASSWORDS):
|
|
261
|
+
warn_about_retired_admin_password()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def ensure_system_user(cursor, *, role: str, auth_source: str, is_postgres: bool) -> None:
|
|
265
|
+
"""Create the signature background jobs sign with -- with no usable password.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
cursor: an open cursor on the users table.
|
|
269
|
+
role: the role to give the account.
|
|
270
|
+
auth_source: what to record as the account's origin.
|
|
271
|
+
is_postgres: whether the connection is PostgreSQL.
|
|
272
|
+
"""
|
|
273
|
+
if _exists(cursor, SYSTEM_USERNAME, is_postgres):
|
|
274
|
+
retire_known_system_password(cursor, is_postgres=is_postgres)
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
_insert(cursor, username=SYSTEM_USERNAME,
|
|
278
|
+
password_hash=hash_password(unusable_password()),
|
|
279
|
+
status=SYSTEM_STATUS, role=role, auth_source=auth_source,
|
|
280
|
+
is_postgres=is_postgres)
|
|
281
|
+
logger.info("Created the service account '%s'; it cannot be signed in as",
|
|
282
|
+
SYSTEM_USERNAME)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def retire_known_system_password(cursor, *, is_postgres: bool,
|
|
286
|
+
candidates: Sequence[str] = RETIRED_SYSTEM_PASSWORDS) -> bool:
|
|
287
|
+
"""Retire the `system` password in a database left by earlier builds.
|
|
288
|
+
|
|
289
|
+
Idempotent: the second time round the password matches none of the known
|
|
290
|
+
ones and no statement runs.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
cursor: an open cursor on the users table.
|
|
294
|
+
is_postgres: whether the connection is PostgreSQL.
|
|
295
|
+
candidates: the retired passwords to look for.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
Whether anything had to be changed.
|
|
299
|
+
"""
|
|
300
|
+
cursor.execute(_sql("SELECT password_hash, status FROM users WHERE username = ?", is_postgres),
|
|
301
|
+
(SYSTEM_USERNAME,))
|
|
302
|
+
row = cursor.fetchone()
|
|
303
|
+
if not row:
|
|
304
|
+
return False
|
|
305
|
+
if hasattr(row, "get") and callable(getattr(row, "get")):
|
|
306
|
+
stored, status = row.get("password_hash"), row.get("status")
|
|
307
|
+
else:
|
|
308
|
+
stored, status = row[0], row[1]
|
|
309
|
+
|
|
310
|
+
if not matches_any(stored, candidates) and status == SYSTEM_STATUS:
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
cursor.execute(
|
|
314
|
+
_sql("UPDATE users SET password_hash = ?, status = ? WHERE username = ?", is_postgres),
|
|
315
|
+
(hash_password(unusable_password()), SYSTEM_STATUS, SYSTEM_USERNAME),
|
|
316
|
+
)
|
|
317
|
+
logger.warning(
|
|
318
|
+
"Service account '%s' carried a password from earlier builds; it has "
|
|
319
|
+
"been retired and the account can no longer be signed in as",
|
|
320
|
+
SYSTEM_USERNAME,
|
|
321
|
+
)
|
|
322
|
+
return True
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""How much life a session has left, and when to warn about it.
|
|
2
|
+
|
|
3
|
+
The access token is a JWT: it carries its own expiry and the server keeps no
|
|
4
|
+
session state, so "when does this session end" is answerable only from the
|
|
5
|
+
token itself. These are the numbers around that -- no request, no database, no
|
|
6
|
+
token decoding -- which is what makes the rule checkable on its own.
|
|
7
|
+
|
|
8
|
+
Why it matters beyond a person's convenience: the host agent logs in the same
|
|
9
|
+
way and authorises its reconnect with the same token. An expired one leaves it
|
|
10
|
+
unable to obtain a websocket key at all, so the host drops out of the system
|
|
11
|
+
while the machine is running.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from datetime import datetime, timedelta
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
#: How long before the end a holder is told to renew. An hour is far longer
|
|
20
|
+
#: than any pause in the agent's work -- it reconnects in seconds and reports
|
|
21
|
+
#: telemetry in minutes -- so a warning always arrives while there is still a
|
|
22
|
+
#: valid token to exchange.
|
|
23
|
+
WARNING_WINDOW = timedelta(hours=1)
|
|
24
|
+
|
|
25
|
+
#: How long a session may keep renewing itself, counted from the login that
|
|
26
|
+
#: started it. Renewal is authorised by the token being renewed and nothing
|
|
27
|
+
#: else, so without a ceiling one login lasts forever and changing a password
|
|
28
|
+
#: takes nothing away from whoever already holds a token.
|
|
29
|
+
#:
|
|
30
|
+
#: The default is long on purpose: the host agent holds its session across a
|
|
31
|
+
#: rental, and asking its owner to type a password mid-rent drops the host out
|
|
32
|
+
#: of the system with a machine still running on it. A month bounds the session
|
|
33
|
+
#: without making that the normal case.
|
|
34
|
+
RENEWAL_WINDOW_ENV = "SESSION_RENEWAL_WINDOW_HOURS"
|
|
35
|
+
DEFAULT_RENEWAL_WINDOW = timedelta(days=30)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def renewal_window(environ=None) -> timedelta:
|
|
39
|
+
"""How long a session may go on being renewed."""
|
|
40
|
+
import os
|
|
41
|
+
|
|
42
|
+
environ = environ if environ is not None else os.environ
|
|
43
|
+
raw = environ.get(RENEWAL_WINDOW_ENV)
|
|
44
|
+
if raw is None:
|
|
45
|
+
return DEFAULT_RENEWAL_WINDOW
|
|
46
|
+
try:
|
|
47
|
+
hours = int(raw)
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
return DEFAULT_RENEWAL_WINDOW
|
|
50
|
+
return timedelta(hours=hours) if hours > 0 else DEFAULT_RENEWAL_WINDOW
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_renewable(
|
|
54
|
+
session_started_at: Optional[datetime],
|
|
55
|
+
now: Optional[datetime] = None,
|
|
56
|
+
environ=None,
|
|
57
|
+
) -> bool:
|
|
58
|
+
"""Whether a session that began then may still be renewed.
|
|
59
|
+
|
|
60
|
+
A session with no recorded beginning is renewable: tokens issued before this
|
|
61
|
+
rule existed carry no such mark, and refusing them would log everybody out
|
|
62
|
+
twice over -- once for the key change, once for this.
|
|
63
|
+
"""
|
|
64
|
+
if session_started_at is None:
|
|
65
|
+
return True
|
|
66
|
+
now = now or datetime.utcnow()
|
|
67
|
+
return now - session_started_at <= renewal_window(environ)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def seconds_left(expires_at: Optional[datetime], now: Optional[datetime] = None) -> int:
|
|
71
|
+
"""Seconds of life the session has left, never negative.
|
|
72
|
+
|
|
73
|
+
A session with no known expiry counts as ended: not knowing when a token
|
|
74
|
+
dies is not a reason to treat it as immortal.
|
|
75
|
+
"""
|
|
76
|
+
if expires_at is None:
|
|
77
|
+
return 0
|
|
78
|
+
now = now or datetime.utcnow()
|
|
79
|
+
remaining = (expires_at - now).total_seconds()
|
|
80
|
+
return max(0, int(remaining))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_expired(expires_at: Optional[datetime], now: Optional[datetime] = None) -> bool:
|
|
84
|
+
"""Whether the session is over."""
|
|
85
|
+
return seconds_left(expires_at, now) <= 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def should_warn(expires_at: Optional[datetime], now: Optional[datetime] = None) -> bool:
|
|
89
|
+
"""Whether the holder should be told to renew now.
|
|
90
|
+
|
|
91
|
+
An already expired session is not warned about: there is nothing left to
|
|
92
|
+
exchange, and the holder will find out from the next refusal.
|
|
93
|
+
"""
|
|
94
|
+
left = seconds_left(expires_at, now)
|
|
95
|
+
return 0 < left <= WARNING_WINDOW.total_seconds()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def can_refresh(expires_at: Optional[datetime], now: Optional[datetime] = None) -> bool:
|
|
99
|
+
"""Whether this session may still be exchanged for a new one.
|
|
100
|
+
|
|
101
|
+
Only a living token buys a new one. Renewing an expired token would make
|
|
102
|
+
the expiry mean nothing: one token issued once would live forever.
|
|
103
|
+
"""
|
|
104
|
+
return not is_expired(expires_at, now)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""The key that signs access tokens, and the single place that resolves it.
|
|
2
|
+
|
|
3
|
+
The key used to live as a module constant next to the provider base class, and
|
|
4
|
+
half the code signed with that constant while the other half read the value out
|
|
5
|
+
of ``auth_config``. Nothing kept the two in step, and the constant shipped in the
|
|
6
|
+
repository: anyone who read it could mint a token for any account, including an
|
|
7
|
+
administrator's. The agent's sources are meant to be opened to host owners, so a
|
|
8
|
+
secret that lives in the source is a secret that is already published.
|
|
9
|
+
|
|
10
|
+
Resolution order is the environment first, ``config/auth.yaml`` second. The
|
|
11
|
+
environment is what a deployment actually controls; the YAML file is committed
|
|
12
|
+
with real-looking values and is an environment, not a template, so it is the
|
|
13
|
+
fallback rather than the source.
|
|
14
|
+
|
|
15
|
+
A key that is absent, too short, or left at one of the placeholder values from
|
|
16
|
+
the setup examples is refused rather than used. The refusal is what the caller
|
|
17
|
+
turns into a failed start: a deployment brought up with a placeholder must not
|
|
18
|
+
come up at all, because the alternative is discovering it the day someone signs
|
|
19
|
+
their own administrator token.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
from typing import Mapping, Optional
|
|
24
|
+
|
|
25
|
+
#: What an application may import from this module. Everything else is
|
|
26
|
+
#: internal and may change without notice -- see doc/keepup.md.
|
|
27
|
+
__all__ = [
|
|
28
|
+
"SigningKeyUnavailable",
|
|
29
|
+
"resolve_signing_key",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
#: Where a deployment puts the key. Named in the refusal, so it stays a constant.
|
|
33
|
+
SIGNING_KEY_ENV = "SECRET_KEY"
|
|
34
|
+
|
|
35
|
+
#: Shortest key accepted. HS256 keys shorter than the digest they feed buy
|
|
36
|
+
#: nothing over a guessable passphrase, and 32 characters is what the token
|
|
37
|
+
#: generators in this repository already produce.
|
|
38
|
+
MINIMUM_KEY_LENGTH = 32
|
|
39
|
+
|
|
40
|
+
#: Values that appear in the setup examples of this repository and in the
|
|
41
|
+
#: documentation. A deployment that still carries one of them has not been
|
|
42
|
+
#: configured, whatever its length.
|
|
43
|
+
PLACEHOLDER_KEYS = frozenset({
|
|
44
|
+
"your-secret-key-here-change-in-production",
|
|
45
|
+
"your-jwt-secret-key-change-in-production",
|
|
46
|
+
"your-secret-key-change-in-production",
|
|
47
|
+
"your-very-secure-secret-key-change-this",
|
|
48
|
+
"change-me",
|
|
49
|
+
"changeme",
|
|
50
|
+
"secret",
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SigningKeyUnavailable(RuntimeError):
|
|
55
|
+
"""No usable signing key: the message says which variable to set."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _configured_key(config: Optional[object]) -> Optional[str]:
|
|
59
|
+
"""Read the key out of the auth configuration, if it carries one."""
|
|
60
|
+
if config is None:
|
|
61
|
+
return None
|
|
62
|
+
value = getattr(config, "jwt_secret", None)
|
|
63
|
+
return value if isinstance(value, str) else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def signing_key_problem(
|
|
67
|
+
environ: Optional[Mapping[str, str]] = None,
|
|
68
|
+
config: Optional[object] = None,
|
|
69
|
+
) -> Optional[str]:
|
|
70
|
+
"""Return why the signing key is unusable, or None when it is fine.
|
|
71
|
+
|
|
72
|
+
Reports rather than raises, so the start-up check can name the problem and
|
|
73
|
+
the resolver can raise on the same verdict.
|
|
74
|
+
"""
|
|
75
|
+
if environ is None:
|
|
76
|
+
environ = os.environ
|
|
77
|
+
if config is None:
|
|
78
|
+
config = _auth_config()
|
|
79
|
+
|
|
80
|
+
candidate = environ.get(SIGNING_KEY_ENV) or _configured_key(config) or ""
|
|
81
|
+
candidate = candidate.strip()
|
|
82
|
+
|
|
83
|
+
if not candidate:
|
|
84
|
+
return (
|
|
85
|
+
f"Token signing secret is not set: set the environment variable "
|
|
86
|
+
f"{SIGNING_KEY_ENV} to a value at least {MINIMUM_KEY_LENGTH} characters long."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if candidate in PLACEHOLDER_KEYS:
|
|
90
|
+
return (
|
|
91
|
+
f"Token signing secret is left at the example configuration value: "
|
|
92
|
+
f"set the environment variable {SIGNING_KEY_ENV} to your own value "
|
|
93
|
+
f"at least {MINIMUM_KEY_LENGTH} characters long."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if len(candidate) < MINIMUM_KEY_LENGTH:
|
|
97
|
+
return (
|
|
98
|
+
f"Token signing secret is shorter than {MINIMUM_KEY_LENGTH} characters: "
|
|
99
|
+
f"set the environment variable {SIGNING_KEY_ENV} to a longer value."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def resolve_signing_key(
|
|
106
|
+
environ: Optional[Mapping[str, str]] = None,
|
|
107
|
+
config: Optional[object] = None,
|
|
108
|
+
) -> str:
|
|
109
|
+
"""Return the key that signs and verifies access tokens.
|
|
110
|
+
|
|
111
|
+
Not cached: reading an environment variable costs nothing next to the
|
|
112
|
+
signature that follows it, and a cache would make the key of a running
|
|
113
|
+
process depend on which test imported it first.
|
|
114
|
+
"""
|
|
115
|
+
if environ is None:
|
|
116
|
+
environ = os.environ
|
|
117
|
+
if config is None:
|
|
118
|
+
config = _auth_config()
|
|
119
|
+
|
|
120
|
+
problem = signing_key_problem(environ, config)
|
|
121
|
+
if problem:
|
|
122
|
+
raise SigningKeyUnavailable(problem)
|
|
123
|
+
|
|
124
|
+
return (environ.get(SIGNING_KEY_ENV) or _configured_key(config) or "").strip()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _auth_config() -> Optional[object]:
|
|
128
|
+
"""The loaded auth configuration, or None when it cannot be imported.
|
|
129
|
+
|
|
130
|
+
Imported lazily: this module is imported by the provider base class, which
|
|
131
|
+
the configuration module does not depend on, and a module-level import would
|
|
132
|
+
close that loop.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
from keepup.auth.config import auth_config
|
|
136
|
+
except Exception: # pragma: no cover - configuration is optional here
|
|
137
|
+
return None
|
|
138
|
+
return auth_config
|
keepup/auth/websocket.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Who is on the other end of a WebSocket.
|
|
2
|
+
|
|
3
|
+
A handshake carries no Authorization header a browser would let a page set, so
|
|
4
|
+
a socket is signed in by one of two things, in this order:
|
|
5
|
+
|
|
6
|
+
* a real token in the address (`?token=`) -- what programmatic clients send;
|
|
7
|
+
* the panel session cookie -- what the panel sends. A handshake is not bound by
|
|
8
|
+
CORS, so the cookie counts only when the page that opened the socket is
|
|
9
|
+
served from this same server; otherwise any site the user visits could open
|
|
10
|
+
a socket as them.
|
|
11
|
+
|
|
12
|
+
Whatever the reason for refusing, the socket is closed with 1008 (policy
|
|
13
|
+
violation) and a short reason; the reasons are the ones the panel and the logs
|
|
14
|
+
already know from the first socket that did this by hand.
|
|
15
|
+
|
|
16
|
+
A plugin gets this without calling anything: a WebSocket route declared with
|
|
17
|
+
`require_auth: True` is signed in by the framework before its handler runs
|
|
18
|
+
(keepup/plugins/registry.py).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from typing import Any, Dict, Optional
|
|
22
|
+
|
|
23
|
+
from fastapi import HTTPException
|
|
24
|
+
|
|
25
|
+
from keepup.auth import dependencies, panel_session
|
|
26
|
+
|
|
27
|
+
#: What an application may import from this module. Everything else is
|
|
28
|
+
#: internal and may change without notice -- see doc/keepup.md.
|
|
29
|
+
__all__ = [
|
|
30
|
+
"WebSocketRefused",
|
|
31
|
+
"authenticate_websocket",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
#: WebSocket close code for "policy violation": the connection is refused on
|
|
35
|
+
#: grounds of who is asking, not because something broke.
|
|
36
|
+
WS_CLOSE_POLICY_VIOLATION = 1008
|
|
37
|
+
|
|
38
|
+
REASON_CROSS_ORIGIN = "Cross-origin session"
|
|
39
|
+
REASON_MISSING_TOKEN = "Missing token"
|
|
40
|
+
REASON_INVALID_TOKEN = "Invalid token"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class WebSocketRefused(Exception):
|
|
44
|
+
"""The socket is not signed in; `code` and `reason` are what it is closed with."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, reason: str, code: int = WS_CLOSE_POLICY_VIOLATION):
|
|
47
|
+
super().__init__(reason)
|
|
48
|
+
self.code = code
|
|
49
|
+
self.reason = reason
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _token_of(websocket) -> str:
|
|
53
|
+
token = panel_session.real_bearer(websocket.query_params.get("token"))
|
|
54
|
+
if token:
|
|
55
|
+
return token
|
|
56
|
+
if not panel_session.same_origin(websocket.headers):
|
|
57
|
+
raise WebSocketRefused(REASON_CROSS_ORIGIN)
|
|
58
|
+
token = panel_session.websocket_token(None, websocket.cookies)
|
|
59
|
+
if not token:
|
|
60
|
+
raise WebSocketRefused(REASON_MISSING_TOKEN)
|
|
61
|
+
return token
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def websocket_user(websocket) -> Dict[str, Any]:
|
|
65
|
+
"""The signed-in user of this socket, or `WebSocketRefused`.
|
|
66
|
+
|
|
67
|
+
The user is resolved through `dependencies.get_current_user` looked up at
|
|
68
|
+
call time, so whatever replaces it (a test, another provider) is honoured.
|
|
69
|
+
"""
|
|
70
|
+
token = _token_of(websocket)
|
|
71
|
+
try:
|
|
72
|
+
user = await dependencies.get_current_user(token=token)
|
|
73
|
+
except HTTPException:
|
|
74
|
+
raise WebSocketRefused(REASON_INVALID_TOKEN)
|
|
75
|
+
if not (user or {}).get("id"):
|
|
76
|
+
raise WebSocketRefused(REASON_INVALID_TOKEN)
|
|
77
|
+
return user
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def authenticate_websocket(websocket) -> Optional[Dict[str, Any]]:
|
|
81
|
+
"""The user of this socket, or None after closing it with the reason."""
|
|
82
|
+
try:
|
|
83
|
+
return await websocket_user(websocket)
|
|
84
|
+
except WebSocketRefused as refused:
|
|
85
|
+
await websocket.close(code=refused.code, reason=refused.reason)
|
|
86
|
+
return None
|