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,7 @@
1
+ """Authentication: providers, dependencies, the panel session and its routes.
2
+
3
+ The provider is chosen by ``config/auth.yaml`` through
4
+ :mod:`keepup.auth.factory`. What the application adds -- documents a person
5
+ must accept, the rule a password is held to, where a sign-in is written down --
6
+ arrives through settings, not from here.
7
+ """
keepup/auth/config.py ADDED
@@ -0,0 +1,128 @@
1
+ """Where the accounts of this deployment live.
2
+
3
+ One source: the application's own ``users`` table. Signing in through an
4
+ external identity provider is not a second source and not a second provider --
5
+ the account is still here, the provider only proves who is knocking. That path
6
+ is configured by the application through ``KeepupSettings.oidc`` and lives in
7
+ :mod:`keepup.auth.oidc`.
8
+
9
+ Until task 170 there was a second provider, an Apache Directory one, which
10
+ wrote into the directory as much as it read from it. It was never enabled on
11
+ any deployment and has been removed; ``provider: "ldap"`` now stops the start
12
+ rather than falling back to local, because a deployment expecting a directory
13
+ and silently getting local sign-in is an open door where nobody looked for one.
14
+ """
15
+
16
+ import os
17
+ from enum import Enum
18
+ from pathlib import Path
19
+ from typing import Any, Dict, Optional
20
+
21
+ import yaml
22
+ from pydantic import BaseModel, ConfigDict, Field
23
+
24
+ from keepup.roles import ROLE_CLIENT
25
+
26
+ #: What an application may import from this module. Everything else is
27
+ #: internal and may change without notice -- see doc/keepup.md.
28
+ __all__ = [
29
+ "AuthConfig",
30
+ "AuthProviderType",
31
+ # Assigned inside a try at module level: the configuration is read on
32
+ # import, and an application takes the object rather than building one.
33
+ "auth_config",
34
+ ]
35
+
36
+ #: Kept as an enum although it has one member: the configuration file says a
37
+ #: name, and a name has to be validated against something.
38
+ class AuthProviderType(str, Enum):
39
+ """Where a deployment keeps its accounts."""
40
+
41
+ LOCAL = "local"
42
+
43
+
44
+ #: Removed providers, named so that a deployment still asking for one is told
45
+ #: what happened instead of quietly getting something else.
46
+ RETIRED_PROVIDERS = {
47
+ "ldap": "the Apache Directory provider was removed in task 170; put an "
48
+ "OpenID Connect provider in front of your directory (Keycloak, for "
49
+ "example) and configure the application with it",
50
+ }
51
+
52
+
53
+ class RetiredProvider(RuntimeError):
54
+ """Raised at start-up for a provider that no longer exists."""
55
+
56
+
57
+ class AuthConfig(BaseModel):
58
+ """The authentication settings of this deployment."""
59
+
60
+ model_config = ConfigDict(validate_default=True)
61
+
62
+ provider: AuthProviderType = Field(AuthProviderType.LOCAL,
63
+ description="Where the accounts live")
64
+ enabled: bool = Field(True, description="Whether authentication is enabled")
65
+ default_role: str = Field(ROLE_CLIENT, description="Default role")
66
+
67
+ #: No default. The placeholder that used to stand here is one of the
68
+ #: values keepup.auth.signing_key refuses outright, so it never worked as
69
+ #: a default -- it only put a key-shaped string into the package.
70
+ jwt_secret: str = Field("", description="JWT signing key; supplied by the deployment")
71
+ jwt_algorithm: str = Field("HS256", description="JWT algorithm")
72
+ jwt_expire_minutes: int = Field(1440, description="Token lifetime, in minutes")
73
+
74
+ @classmethod
75
+ def from_yaml(cls, config_path: Path = Path("config/auth.yaml")) -> "AuthConfig":
76
+ """Load the configuration from a YAML file."""
77
+ if not config_path.exists():
78
+ raise FileNotFoundError(f"Configuration file not found: {config_path}")
79
+
80
+ with open(config_path, 'r', encoding='utf-8') as f:
81
+ config_data = yaml.safe_load(f) or {}
82
+
83
+ return cls.from_mapping(config_data)
84
+
85
+ @classmethod
86
+ def from_mapping(cls, config_data: Dict[str, Any]) -> "AuthConfig":
87
+ """Build the configuration from already parsed data."""
88
+ section = config_data.get("auth", config_data)
89
+ provider_str = str(section.get("provider", "local")).lower()
90
+
91
+ if provider_str in RETIRED_PROVIDERS:
92
+ raise RetiredProvider(
93
+ f"config/auth.yaml asks for the '{provider_str}' provider: "
94
+ f"{RETIRED_PROVIDERS[provider_str]}")
95
+
96
+ try:
97
+ provider = AuthProviderType(provider_str)
98
+ except ValueError:
99
+ print(f"Unknown provider '{provider_str}', falling back to 'local'")
100
+ provider = AuthProviderType.LOCAL
101
+
102
+ jwt_config = config_data.get("jwt", {}) or {}
103
+
104
+ return cls(
105
+ provider=provider,
106
+ enabled=section.get("enabled", True),
107
+ default_role=section.get("default_role", ROLE_CLIENT),
108
+ jwt_secret=jwt_config.get("secret", ""),
109
+ jwt_algorithm=jwt_config.get("algorithm", "HS256"),
110
+ jwt_expire_minutes=jwt_config.get("expire_minutes", 1440),
111
+ )
112
+
113
+
114
+ try:
115
+ config_path = Path(os.environ.get("AUTH_CONFIG_PATH", "config/auth.yaml"))
116
+ auth_config = AuthConfig.from_yaml(config_path)
117
+ print(f"Authentication configuration loaded from {config_path}")
118
+ print(f"Provider in use: {auth_config.provider}")
119
+ except RetiredProvider:
120
+ # Never swallowed: the whole point is that this deployment must not come up
121
+ # signing people in some other way than it asked for.
122
+ raise
123
+ except FileNotFoundError as e:
124
+ print(f"Warning: {e}. Falling back to the default configuration.")
125
+ auth_config = AuthConfig()
126
+ except Exception as e:
127
+ print(f"Could not load the configuration: {e}. Falling back to the default one.")
128
+ auth_config = AuthConfig()
@@ -0,0 +1,485 @@
1
+ """Who is making this request, as FastAPI dependencies.
2
+
3
+ `get_current_user` and `get_current_admin` are what a route says instead of
4
+ checking a token itself; every plugin route generated by the registry gets the
5
+ result handed to it. Issuing and reading tokens lives here too, at module level
6
+ rather than inside the route, so that a sign-in can be exercised without HTTP.
7
+
8
+ The password rule and where a sign-in is written down are not decided here: the
9
+ application supplies both through KeepupSettings, because a rule the framework
10
+ invented would be a different rule from the one its invitations enforce.
11
+ """
12
+
13
+ import asyncio
14
+ import logging
15
+ from datetime import timedelta, datetime
16
+ from typing import Optional, List, Dict
17
+
18
+ import bcrypt
19
+ from fastapi import HTTPException, Depends, Request
20
+ from jose import jwt, JWTError
21
+ from starlette import status
22
+
23
+ from keepup.auth import panel_session
24
+ from keepup.auth.dto.token import TokenData
25
+ from keepup.auth.factory import AuthProviderFactory
26
+ from keepup.auth.providers.base import AuthProvider, ALGORITHM, oauth2_scheme
27
+ from keepup.auth.signing_key import resolve_signing_key
28
+ from keepup.roles import ROLE_ADMIN, ROLE_CLIENT
29
+ from keepup.auth.config import auth_config, AuthProviderType
30
+ from keepup.db import DatabaseManager, db_config, DatabaseManagerV2
31
+
32
+ #: What an application may import from this module. Everything else is
33
+ #: internal and may change without notice -- see doc/keepup.md.
34
+ __all__ = [
35
+ "create_user",
36
+ "get_all_users",
37
+ "get_current_admin",
38
+ "get_current_user",
39
+ "get_optional_user",
40
+ "get_panel_user",
41
+ "get_user_by_id",
42
+ "get_user_by_username",
43
+ "issue_session_token",
44
+ ]
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ from keepup.auth import seed_accounts
49
+
50
+ auth_provider: AuthProvider = AuthProviderFactory.get_provider()
51
+
52
+
53
+ async def get_user_by_username_async(username: str):
54
+ """Return a user by name, including the extra fields."""
55
+ return await auth_provider.get_user_info(username)
56
+
57
+
58
+ def get_user_by_username(username: str):
59
+ """Return a user by name, including the extra fields."""
60
+ try:
61
+ loop = asyncio.get_event_loop()
62
+ except RuntimeError:
63
+ loop = asyncio.new_event_loop()
64
+ asyncio.set_event_loop(loop)
65
+
66
+ if loop.is_running():
67
+ import asyncio as async_module
68
+ import threading
69
+
70
+ result = None
71
+ event = threading.Event()
72
+
73
+ def run_in_thread():
74
+ nonlocal result
75
+ new_loop = async_module.new_event_loop()
76
+ try:
77
+ async_module.set_event_loop(new_loop)
78
+ result = new_loop.run_until_complete(
79
+ auth_provider.get_user_info(username)
80
+ )
81
+ except Exception as e:
82
+ logger.error(f"Error in thread: {e}")
83
+ finally:
84
+ # Closed on every path, including the failing one: a loop holds
85
+ # an epoll descriptor and a self-pipe, and this runs once per
86
+ # authenticated call. Left open, a long-lived process reaches its
87
+ # descriptor limit and then cannot open anything at all -- which
88
+ # surfaces as "database connection failed", not as a leak.
89
+ try:
90
+ new_loop.close()
91
+ except Exception as e:
92
+ logger.warning(f"Could not close the worker event loop: {e}")
93
+ async_module.set_event_loop(None)
94
+ event.set()
95
+
96
+ thread = threading.Thread(target=run_in_thread)
97
+ thread.start()
98
+ thread.join(timeout=10)
99
+
100
+ if event.is_set() and result is not None:
101
+ return result
102
+ else:
103
+ logger.warning(f"Async call failed, falling back to DB for user {username}")
104
+ return None
105
+
106
+ user_info = loop.run_until_complete(
107
+ auth_provider.get_user_info(username)
108
+ )
109
+
110
+ return user_info
111
+
112
+
113
+ def get_user_by_id(user_id: int):
114
+ """Return a user by id, for any configured auth provider."""
115
+ # TODO: auth_provider needs a lookup by id as well; without it this only
116
+ # works against the local database.
117
+ result = DatabaseManager.execute_query(
118
+ "SELECT * FROM users WHERE id = ?",
119
+ (user_id,),
120
+ fetch_one=True
121
+ )
122
+ return result
123
+
124
+
125
+ def get_system_user_id():
126
+ """Return the id of the system user that background work is signed with.
127
+
128
+ The account itself is created once, at start-up, by
129
+ `keepup.auth.seed_accounts` -- without a password anyone could sign in with.
130
+ Creating it here as well is what let three spellings of it drift apart; a
131
+ missing account now means the schema has not been initialised, and inventing
132
+ one with a password of our own would only hide that.
133
+ """
134
+ try:
135
+ system_user = get_user_by_username(seed_accounts.SYSTEM_USERNAME)
136
+ if system_user:
137
+ return system_user['id']
138
+ logger.error(
139
+ "The service account '%s' is not in the database: the schema was "
140
+ "not initialised. Background work will go unsigned.",
141
+ seed_accounts.SYSTEM_USERNAME)
142
+ return None
143
+ except Exception as e:
144
+ logger.error(f"Error getting system user: {e}")
145
+ return None
146
+
147
+
148
+ def get_all_users():
149
+ """Return every user, for any configured auth provider."""
150
+ return DatabaseManager.execute_query(
151
+ "SELECT id, username, status, role, created_at FROM users ORDER BY created_at DESC"
152
+ )
153
+
154
+
155
+ def save_user_to_db(username: str, password: str):
156
+ """Create a user, for any configured auth provider."""
157
+ password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
158
+
159
+ query = """
160
+ INSERT INTO users (username, password_hash, status, role)
161
+ VALUES (?, ?, ?, ?)
162
+ """
163
+
164
+ if db_config.is_postgres():
165
+ query += " RETURNING id"
166
+
167
+ try:
168
+ user_id = DatabaseManager.execute_commit(
169
+ query,
170
+ (username, password_hash, "blocked", ROLE_CLIENT)
171
+ )
172
+ return user_id
173
+
174
+ except Exception as e:
175
+ if "unique constraint" in str(e).lower() or "duplicate" in str(e).lower():
176
+ raise HTTPException(status_code=400, detail="Username already exists")
177
+ raise e
178
+
179
+
180
+ async def create_user(
181
+ username: str,
182
+ password: str,
183
+ email: str = None,
184
+ phone: str = None,
185
+ full_name: str = None,
186
+ agree_terms: bool = False
187
+ ):
188
+ user_id = auth_provider.create_user_in_db(username, password, email, phone, full_name, agree_terms)
189
+
190
+ user_data = {"user_id": user_id, "username": (username,), "password": (password,), "email": email, "phone": phone,
191
+ "full_name": full_name, "agree_terms": agree_terms}
192
+
193
+ await auth_provider.create_user(user_data)
194
+
195
+ # The id is returned to the caller: it was computed and then thrown away,
196
+ # while callers assigned the result to a variable and got None. The nearest
197
+ # consequence was that creating test users considered itself unsuccessful
198
+ # every time and never got as far as their wallets.
199
+ return user_id
200
+
201
+
202
+ def update_user(user_id: int, status: Optional[str] = None, role: Optional[str] = None,
203
+ email: Optional[str] = None, phone: Optional[str] = None, full_name: Optional[str] = None):
204
+ """Update a user, including the extra fields."""
205
+ conn = DatabaseManager.get_connection()
206
+ cursor = conn.cursor()
207
+
208
+ update_fields = []
209
+ params = []
210
+
211
+ if status is not None:
212
+ update_fields.append("status = ?")
213
+ params.append(status)
214
+
215
+ if role is not None:
216
+ update_fields.append("role = ?")
217
+ params.append(role)
218
+
219
+ if email is not None:
220
+ update_fields.append("email = ?")
221
+ params.append(email)
222
+
223
+ if phone is not None:
224
+ update_fields.append("phone = ?")
225
+ params.append(phone)
226
+
227
+ if full_name is not None:
228
+ update_fields.append("full_name = ?")
229
+ params.append(full_name)
230
+
231
+ if not update_fields:
232
+ return
233
+
234
+ update_fields.append("updated_at = CURRENT_TIMESTAMP")
235
+ params.append(user_id)
236
+
237
+ query = f"UPDATE users SET {', '.join(update_fields)} WHERE id = ?"
238
+
239
+ if db_config.is_postgres():
240
+ query = query.replace('?', '%s')
241
+
242
+ cursor.execute(query, params)
243
+ conn.commit()
244
+ cursor.close()
245
+ conn.close()
246
+
247
+
248
+ async def authenticate(username: str, password: str):
249
+ return await auth_provider.authenticate(username=username, password=password)
250
+
251
+
252
+ def verify_password(plain_password: str, hashed_password: str):
253
+ return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
254
+
255
+
256
+ #: When the session this token belongs to began. Carried by the token rather
257
+ #: than kept on the server, which keeps no session state; renewal reads it to
258
+ #: know whether the session has gone on long enough.
259
+ SESSION_START_CLAIM = "sst"
260
+
261
+
262
+ def create_access_token(
263
+ data: dict,
264
+ expires_delta: Optional[timedelta] = None,
265
+ session_started_at: Optional[datetime] = None,
266
+ ):
267
+ to_encode = data.copy()
268
+ if expires_delta:
269
+ expire = datetime.utcnow() + expires_delta
270
+ else:
271
+ expire = datetime.utcnow() + timedelta(minutes=15)
272
+ to_encode.update({"exp": expire})
273
+
274
+ # A renewal carries the beginning of the session forward unchanged. Stamping
275
+ # "now" on every renewal is what made a session endless: each exchange would
276
+ # reset the clock that is supposed to be running out.
277
+ started = session_started_at or datetime.utcnow()
278
+ to_encode.setdefault(SESSION_START_CLAIM, int(started.timestamp()))
279
+
280
+ encoded_jwt = jwt.encode(to_encode, resolve_signing_key(), algorithm=ALGORITHM)
281
+ return encoded_jwt
282
+
283
+
284
+ def issue_session_token(user_id: int, username: str, sid: Optional[str] = None,
285
+ session_started_at: Optional[datetime] = None) -> dict:
286
+ """A token bound to a session record: a new session, or `sid` carried on.
287
+
288
+ Every token the server hands a person or an agent goes through here, so every
289
+ one of them can be revoked.
290
+ """
291
+ from keepup.auth.providers.base import ACCESS_TOKEN_EXPIRE_MINUTES
292
+ lifetime = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
293
+ if sid is None:
294
+ sid = panel_session.open_session(user_id, lifetime)
295
+ else:
296
+ panel_session.extend(sid, lifetime)
297
+ token = create_access_token({"sub": username, panel_session.SESSION_CLAIM: sid},
298
+ expires_delta=lifetime, session_started_at=session_started_at)
299
+ return {"access_token": token, "session_id": sid,
300
+ "expires_in": int(lifetime.total_seconds())}
301
+
302
+
303
+ def _session_start(payload: dict) -> Optional[datetime]:
304
+ """Read the session's beginning out of a decoded token, or None."""
305
+ started = payload.get(SESSION_START_CLAIM)
306
+ if started is None:
307
+ return None
308
+ try:
309
+ return datetime.utcfromtimestamp(int(started))
310
+ except (TypeError, ValueError, OSError):
311
+ return None
312
+
313
+
314
+ async def request_token(request: Request, bearer: Optional[str] = Depends(oauth2_scheme)) -> Optional[str]:
315
+ """The request's token: a real bearer header, else the panel session cookie."""
316
+ return panel_session.token_from_request(request, bearer)
317
+
318
+
319
+ def _session_is_live(payload: dict) -> bool:
320
+ """A token that names a session is good only while that session is.
321
+
322
+ A token without one was issued before sessions were recorded and is accepted
323
+ until it expires -- at most a day after the deployment.
324
+ """
325
+ sid = payload.get(panel_session.SESSION_CLAIM)
326
+ if sid is None:
327
+ return True
328
+ try:
329
+ return panel_session.is_active(sid)
330
+ except Exception as error:
331
+ # Failing open here would make revocation depend on the database being
332
+ # up; failing closed only costs a retry.
333
+ logger.error(f"Could not check session {str(sid)[:8]}...: {error}")
334
+ return False
335
+
336
+
337
+ async def get_current_user(token: str = Depends(request_token)):
338
+ """Return the current user of this token.
339
+
340
+ The signature carries nothing but the token, and that is a rule rather than
341
+ a detail: a scalar parameter with a default on a dependency is a *query*
342
+ parameter to FastAPI. One used to stand here -- `ignore_empty_user` -- and
343
+ it was published on every route of the framework and of every plugin, as a
344
+ switch a caller could set to turn the check off. Code that wants None
345
+ instead of a refusal calls get_optional_user() below, which is not a
346
+ dependency and takes the token as an argument.
347
+
348
+ One branch since task 170: the accounts live in this application's table.
349
+ Somebody who signed in through an external provider is here too -- that
350
+ path ends by issuing this same session, so by the time a request arrives
351
+ there is nothing left to tell apart.
352
+ """
353
+ return await _get_local_user(token)
354
+
355
+
356
+ def _token_expiry(payload: dict) -> Optional[datetime]:
357
+ """Read the expiry out of a decoded token payload, or None."""
358
+ expires_at = payload.get("exp")
359
+ if expires_at is None:
360
+ return None
361
+ try:
362
+ return datetime.utcfromtimestamp(int(expires_at))
363
+ except (TypeError, ValueError, OSError):
364
+ return None
365
+
366
+
367
+ async def _get_local_user(token: Optional[str], ignore_empty_user: bool = False):
368
+ credentials_exception = HTTPException(
369
+ status_code=status.HTTP_401_UNAUTHORIZED,
370
+ detail="Could not validate credentials",
371
+ headers={"WWW-Authenticate": "Bearer"},
372
+ )
373
+ try:
374
+ if token is None:
375
+ if ignore_empty_user:
376
+ return None
377
+ raise credentials_exception
378
+ payload = jwt.decode(token, resolve_signing_key(), algorithms=[ALGORITHM])
379
+ username: str = payload.get("sub")
380
+ if username is None:
381
+ if ignore_empty_user:
382
+ return None
383
+ raise credentials_exception
384
+ token_data = TokenData(username=username)
385
+ except JWTError:
386
+ if ignore_empty_user:
387
+ return None
388
+ raise credentials_exception
389
+
390
+ if not _session_is_live(payload):
391
+ raise credentials_exception
392
+
393
+ user = get_user_by_username(token_data.username)
394
+ if user is None:
395
+ raise credentials_exception
396
+
397
+ fresh_user = get_user_by_username(token_data.username)
398
+ if fresh_user["status"] != "active":
399
+ raise HTTPException(
400
+ status_code=status.HTTP_403_FORBIDDEN,
401
+ detail="User account is blocked",
402
+ )
403
+
404
+ # When this session ends. The server keeps no session state, so the moment
405
+ # the token is decoded is the only place that knows -- and a handler that
406
+ # needs to warn about the end (or record it alongside a websocket key)
407
+ # cannot decode the token itself: it never sees it.
408
+ fresh_user = dict(fresh_user)
409
+ fresh_user["token_expires_at"] = _token_expiry(payload)
410
+ fresh_user["session_started_at"] = _session_start(payload)
411
+ fresh_user["session_id"] = payload.get(panel_session.SESSION_CLAIM)
412
+
413
+ return fresh_user
414
+
415
+
416
+ async def get_optional_user(token: Optional[str]):
417
+ """The user of this token, or None when there is none.
418
+
419
+ For callers outside the request cycle -- a route that serves both a signed-in
420
+ user and a guest, a socket that authenticates itself. Deliberately not a
421
+ dependency: see get_current_user() for what happens when a switch like this
422
+ ends up in a dependency's signature.
423
+
424
+ Args:
425
+ token: the bearer or panel token, or None.
426
+
427
+ Returns:
428
+ The user, or None when the token is absent, unreadable or names nobody.
429
+ """
430
+ return await _get_local_user(token, ignore_empty_user=True)
431
+
432
+
433
+ async def get_current_admin(current_user: dict = Depends(get_current_user)):
434
+ if current_user["role"] != ROLE_ADMIN:
435
+ raise HTTPException(
436
+ status_code=status.HTTP_403_FORBIDDEN,
437
+ detail="Admin access required",
438
+ )
439
+ return current_user
440
+
441
+
442
+ # --- The panel session's user -------------------------------------------------
443
+
444
+ #: Set by the application through configure_panel_gate(): given a user, it
445
+ #: returns the documents that user has yet to accept. The framework has no
446
+ #: documents of its own, so without it the gate is simply open.
447
+ pending_documents = None
448
+
449
+
450
+ def configure_panel_gate(pending=None):
451
+ """Supply the application's "documents this user must accept" rule.
452
+
453
+ Setting it to None opens the gate: an application with no documents is not
454
+ the same as an application that keeps the previous one's.
455
+ """
456
+ global pending_documents
457
+ pending_documents = pending
458
+
459
+
460
+ def _authenticated_by_cookie(request: Request) -> bool:
461
+ return panel_session.real_bearer(_bearer_of(request)) is None \
462
+ and bool(request.cookies.get(panel_session.SESSION_COOKIE))
463
+
464
+
465
+ def _bearer_of(request: Request) -> Optional[str]:
466
+ scheme, _, value = (request.headers.get("authorization") or "").partition(" ")
467
+ return value if scheme.lower() == "bearer" else None
468
+
469
+
470
+ async def get_panel_user(request: Request, current_user: dict = Depends(get_current_user)):
471
+ """The user of a plugin route, refused while a required document is not accepted.
472
+
473
+ Only for the panel's cookie session. An agent or a script sends Bearer, and a
474
+ new version of a document must not stop an agent in the middle of a rental;
475
+ what a developer needs a script for -- an API key -- is created in the panel.
476
+ """
477
+ if _authenticated_by_cookie(request) and pending_documents is not None:
478
+ missing = pending_documents(current_user)
479
+ if missing:
480
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail={
481
+ "code": "agreements_required",
482
+ "message": "Accept " + ", ".join(d.title for d in missing) + " to continue",
483
+ "pending": [d.id for d in missing],
484
+ })
485
+ return current_user
File without changes
@@ -0,0 +1,31 @@
1
+ """What a sign-in hands back, and what a token carries.
2
+
3
+ Two shapes rather than dictionaries: the reply is part of the API and the claim
4
+ set is read on every request, and both used to be assembled by hand in several
5
+ places that disagreed about the field names.
6
+ """
7
+
8
+ from typing import Optional
9
+
10
+ from pydantic import BaseModel
11
+
12
+ #: What an application may import from this module. Everything else is
13
+ #: internal and may change without notice -- see doc/keepup.md.
14
+ __all__ = [
15
+ "Token",
16
+ "TokenData",
17
+ ]
18
+
19
+
20
+ class Token(BaseModel):
21
+ """What a successful sign-in hands back."""
22
+
23
+ access_token: str
24
+ token_type: str
25
+ success: bool = True
26
+
27
+
28
+ class TokenData(BaseModel):
29
+ """The claims read back out of a token."""
30
+
31
+ username: Optional[str] = None
keepup/auth/factory.py ADDED
@@ -0,0 +1,28 @@
1
+ """Which provider answers "is this the right password".
2
+
3
+ Since task 170 there is one answer -- accounts live in this application's own
4
+ table -- but the choice stays a lookup rather than a call of LocalAuthProvider:
5
+ `config/auth.yaml` names a provider, and a name that no longer exists has to
6
+ stop the start with a message instead of silently becoming the local one.
7
+ """
8
+
9
+ from keepup.auth.providers.base import AuthProvider
10
+ from keepup.auth.config import auth_config, AuthProviderType
11
+ from keepup.auth.providers.local import LocalAuthProvider
12
+
13
+
14
+ class AuthProviderFactory:
15
+ """Factory creating authentication providers."""
16
+
17
+ #: One entry since task 170: accounts live in this application's table.
18
+ #: Signing in through an external provider is a separate path, not another
19
+ #: entry here -- that path never receives a password to check.
20
+ _providers = {
21
+ AuthProviderType.LOCAL: LocalAuthProvider,
22
+ }
23
+
24
+ @classmethod
25
+ def get_provider(cls) -> AuthProvider:
26
+ """Return the currently configured authentication provider."""
27
+ provider_class = cls._providers.get(auth_config.provider, LocalAuthProvider)
28
+ return provider_class()