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.
Files changed (86) hide show
  1. keepup/THIRD-PARTY.md +44 -0
  2. keepup/__init__.py +41 -0
  3. keepup/api_versions.py +100 -0
  4. keepup/audit.py +499 -0
  5. keepup/auth/__init__.py +7 -0
  6. keepup/auth/config.py +128 -0
  7. keepup/auth/dependencies.py +485 -0
  8. keepup/auth/dto/__init__.py +0 -0
  9. keepup/auth/dto/token.py +31 -0
  10. keepup/auth/factory.py +28 -0
  11. keepup/auth/login_throttle.py +151 -0
  12. keepup/auth/oidc.py +245 -0
  13. keepup/auth/oidc_policy.py +111 -0
  14. keepup/auth/oidc_routes.py +288 -0
  15. keepup/auth/panel_session.py +220 -0
  16. keepup/auth/permissions.py +59 -0
  17. keepup/auth/providers/__init__.py +0 -0
  18. keepup/auth/providers/base.py +168 -0
  19. keepup/auth/providers/local.py +180 -0
  20. keepup/auth/routes.py +660 -0
  21. keepup/auth/seed_accounts.py +322 -0
  22. keepup/auth/session_lifetime.py +104 -0
  23. keepup/auth/signing_key.py +138 -0
  24. keepup/auth/websocket.py +86 -0
  25. keepup/cluster.py +634 -0
  26. keepup/db.py +663 -0
  27. keepup/events.py +764 -0
  28. keepup/factory.py +484 -0
  29. keepup/instance.py +46 -0
  30. keepup/integrations.py +260 -0
  31. keepup/locks.py +412 -0
  32. keepup/logging_setup.py +690 -0
  33. keepup/metrics.py +818 -0
  34. keepup/metrics_retention.py +376 -0
  35. keepup/modules.py +572 -0
  36. keepup/notification_bus.py +355 -0
  37. keepup/plugins/__init__.py +9 -0
  38. keepup/plugins/admin.py +246 -0
  39. keepup/plugins/base.py +234 -0
  40. keepup/plugins/enablement.py +184 -0
  41. keepup/plugins/registry.py +171 -0
  42. keepup/plugins/route_mask.py +338 -0
  43. keepup/plugins/routes.py +376 -0
  44. keepup/roles.py +17 -0
  45. keepup/scheduler.py +93 -0
  46. keepup/schema.py +295 -0
  47. keepup/sections.json +104 -0
  48. keepup/settings.py +184 -0
  49. keepup/static/css/aos.css +1 -0
  50. keepup/static/css/main_nebula.css +232 -0
  51. keepup/static/css/main_new.css +852 -0
  52. keepup/static/css/tailwind.css +1 -0
  53. keepup/static/index_nebula.html +293 -0
  54. keepup/static/index_new.html +286 -0
  55. keepup/static/js/aos.js +1 -0
  56. keepup/static/js/feather-icons.js +13 -0
  57. keepup/static/js/main_new.js +1861 -0
  58. keepup/static/js/tailwind.js +83 -0
  59. keepup/static/modules/css/background_tasks.css +233 -0
  60. keepup/static/modules/css/cluster.css +16 -0
  61. keepup/static/modules/css/event_manager.css +386 -0
  62. keepup/static/modules/css/integration_logs.css +33 -0
  63. keepup/static/modules/css/metrics.css +115 -0
  64. keepup/static/modules/css/modules.css +189 -0
  65. keepup/static/modules/css/themes.css +563 -0
  66. keepup/static/modules/css/users.css +278 -0
  67. keepup/static/modules/js/background_tasks.js +657 -0
  68. keepup/static/modules/js/chart.js +14 -0
  69. keepup/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
  70. keepup/static/modules/js/cluster.js +363 -0
  71. keepup/static/modules/js/event_manager.js +979 -0
  72. keepup/static/modules/js/integration_logs.js +767 -0
  73. keepup/static/modules/js/metrics.js +908 -0
  74. keepup/static/modules/js/modules.js +1086 -0
  75. keepup/static/modules/js/themes.js +653 -0
  76. keepup/static/modules/js/users.js +784 -0
  77. keepup/tables.py +302 -0
  78. keepup/themes.py +496 -0
  79. keepup/web.py +182 -0
  80. keepup_admin-0.1.0.dist-info/METADATA +117 -0
  81. keepup_admin-0.1.0.dist-info/RECORD +86 -0
  82. keepup_admin-0.1.0.dist-info/WHEEL +5 -0
  83. keepup_admin-0.1.0.dist-info/licenses/LICENSE +202 -0
  84. keepup_admin-0.1.0.dist-info/licenses/NOTICE +22 -0
  85. keepup_admin-0.1.0.dist-info/licenses/THIRD-PARTY.md +44 -0
  86. 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
@@ -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