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
keepup/auth/__init__.py
ADDED
|
@@ -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
|
keepup/auth/dto/token.py
ADDED
|
@@ -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()
|