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/routes.py
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
"""Signing in, the panel session and the management of users.
|
|
2
|
+
|
|
3
|
+
The endpoints here are the framework's: they work against the ``users`` table
|
|
4
|
+
and the configured authentication provider, and know nothing about what the
|
|
5
|
+
application sells. What the application does add -- documents a user must
|
|
6
|
+
accept, a notice when an account is blocked -- arrives through the plugin
|
|
7
|
+
handlers and through the panel gate in ``keepup.auth.dependencies``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from datetime import datetime, timedelta
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
import bcrypt
|
|
18
|
+
from fastapi import Depends, HTTPException, Request, Response, status
|
|
19
|
+
from jose import JWTError, jwt
|
|
20
|
+
from pydantic import BaseModel, Field, field_validator
|
|
21
|
+
|
|
22
|
+
from keepup.auth import panel_session
|
|
23
|
+
from keepup.auth.config import AuthProviderType, auth_config
|
|
24
|
+
from keepup.auth.dependencies import (
|
|
25
|
+
_authenticated_by_cookie,
|
|
26
|
+
_bearer_of,
|
|
27
|
+
authenticate,
|
|
28
|
+
create_user,
|
|
29
|
+
create_access_token,
|
|
30
|
+
get_all_users,
|
|
31
|
+
get_current_admin,
|
|
32
|
+
get_current_user,
|
|
33
|
+
get_user_by_id,
|
|
34
|
+
get_user_by_username,
|
|
35
|
+
get_user_by_username_async,
|
|
36
|
+
issue_session_token,
|
|
37
|
+
update_user,
|
|
38
|
+
verify_password,
|
|
39
|
+
)
|
|
40
|
+
from keepup.auth.dto.token import Token
|
|
41
|
+
from keepup.auth.factory import AuthProviderFactory
|
|
42
|
+
from keepup.auth.providers.base import ACCESS_TOKEN_EXPIRE_MINUTES, ALGORITHM
|
|
43
|
+
from keepup.auth.signing_key import resolve_signing_key
|
|
44
|
+
from keepup.db import DatabaseManager, DatabaseManagerV2, db_config
|
|
45
|
+
from keepup.instance import get_instance_id
|
|
46
|
+
from keepup.roles import ROLE_ADMIN, ROLE_CLIENT
|
|
47
|
+
|
|
48
|
+
#: What an application may import from this module. Everything else is
|
|
49
|
+
#: internal and may change without notice -- see doc/keepup.md.
|
|
50
|
+
__all__ = [
|
|
51
|
+
"SELF_REGISTRATION_ENV",
|
|
52
|
+
"UserCreate",
|
|
53
|
+
"UserResponse",
|
|
54
|
+
"configure",
|
|
55
|
+
"get_user_from_database_by_username",
|
|
56
|
+
"refresh_access_token",
|
|
57
|
+
"register",
|
|
58
|
+
"configure_public_config",
|
|
59
|
+
"self_registration_enabled",
|
|
60
|
+
"update_user_profile",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
logger = logging.getLogger(__name__)
|
|
64
|
+
|
|
65
|
+
#: Whether anyone may create an account without an invitation.
|
|
66
|
+
SELF_REGISTRATION_ENV = "SELF_REGISTRATION_ENABLED"
|
|
67
|
+
|
|
68
|
+
#: Supplied by the application: how strict a password must be, and where a
|
|
69
|
+
#: successful sign-in is written down. The framework has no opinion on either
|
|
70
|
+
#: -- the password rule is shared with the application's invitation path, and
|
|
71
|
+
#: what belongs in an audit trail is the application's catalogue of events.
|
|
72
|
+
password_rule = None
|
|
73
|
+
record_login = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_UNSET = object()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _documents_pending(user: dict) -> bool:
|
|
80
|
+
"""Whether the application says this person has documents left to accept.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
user: The signed-in user.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
False when the application declared no documents at all -- which is
|
|
87
|
+
every application but one, and then the panel shell must not ask for
|
|
88
|
+
them: the route belongs to the application that has them.
|
|
89
|
+
"""
|
|
90
|
+
from keepup.auth import dependencies
|
|
91
|
+
|
|
92
|
+
rule = dependencies.pending_documents
|
|
93
|
+
if rule is None:
|
|
94
|
+
return False
|
|
95
|
+
try:
|
|
96
|
+
return bool(rule(user))
|
|
97
|
+
except Exception as error:
|
|
98
|
+
# A failure here must not stand between a person and the panel: the
|
|
99
|
+
# gate itself refuses on its own terms, this is only the hint for the
|
|
100
|
+
# shell.
|
|
101
|
+
logger.warning(f"Could not tell whether documents are pending: {error}")
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def configure(password=_UNSET, login_record=_UNSET):
|
|
106
|
+
"""Supply the application's password rule and sign-in record.
|
|
107
|
+
|
|
108
|
+
None is a value, not a silence: an application that holds passwords to no
|
|
109
|
+
rule of its own has to be able to say so without inheriting the previous
|
|
110
|
+
one's.
|
|
111
|
+
"""
|
|
112
|
+
global password_rule, record_login
|
|
113
|
+
if password is not _UNSET:
|
|
114
|
+
password_rule = password
|
|
115
|
+
if login_record is not _UNSET:
|
|
116
|
+
record_login = login_record
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class UserBase(BaseModel):
|
|
121
|
+
"""The fields every shape of a user shares."""
|
|
122
|
+
|
|
123
|
+
username: str
|
|
124
|
+
class LoginRequest(BaseModel):
|
|
125
|
+
"""Credentials presented at sign-in."""
|
|
126
|
+
|
|
127
|
+
username: str
|
|
128
|
+
password: str
|
|
129
|
+
class UserCreate(UserBase):
|
|
130
|
+
"""A registration, validated before it reaches the database."""
|
|
131
|
+
|
|
132
|
+
password: str
|
|
133
|
+
email: Optional[str] = None
|
|
134
|
+
phone: Optional[str] = None
|
|
135
|
+
full_name: Optional[str] = None
|
|
136
|
+
agree_terms: bool = Field(False, description="Agreement with the terms of use")
|
|
137
|
+
|
|
138
|
+
@field_validator('email')
|
|
139
|
+
def validate_email(cls, v):
|
|
140
|
+
if v and not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', v):
|
|
141
|
+
raise ValueError('Invalid email format')
|
|
142
|
+
return v
|
|
143
|
+
|
|
144
|
+
@field_validator('phone')
|
|
145
|
+
def validate_phone(cls, v):
|
|
146
|
+
if v:
|
|
147
|
+
v = re.sub(r'\D', '', v)
|
|
148
|
+
if len(v) < 10:
|
|
149
|
+
raise ValueError('Phone number must be at least 10 digits')
|
|
150
|
+
return v
|
|
151
|
+
|
|
152
|
+
@field_validator('agree_terms')
|
|
153
|
+
def validate_agree_terms(cls, v):
|
|
154
|
+
if not v:
|
|
155
|
+
raise ValueError('You must agree to the terms of use')
|
|
156
|
+
return v
|
|
157
|
+
class UserResponse(BaseModel):
|
|
158
|
+
"""A user as the API describes them; no password field ever leaves here."""
|
|
159
|
+
|
|
160
|
+
id: int
|
|
161
|
+
username: str
|
|
162
|
+
email: Optional[str] = None
|
|
163
|
+
phone: Optional[str] = None
|
|
164
|
+
full_name: Optional[str] = None
|
|
165
|
+
agree_terms: bool = False
|
|
166
|
+
status: str
|
|
167
|
+
role: str
|
|
168
|
+
created_at: datetime
|
|
169
|
+
updated_at: Optional[datetime] = None
|
|
170
|
+
class UserUpdateRequest(BaseModel):
|
|
171
|
+
"""What an administrator may change about a user."""
|
|
172
|
+
|
|
173
|
+
status: Optional[str] = None
|
|
174
|
+
role: Optional[str] = None
|
|
175
|
+
email: Optional[str] = None
|
|
176
|
+
phone: Optional[str] = None
|
|
177
|
+
full_name: Optional[str] = None
|
|
178
|
+
|
|
179
|
+
@field_validator('email')
|
|
180
|
+
def validate_email(cls, v):
|
|
181
|
+
if v and not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', v):
|
|
182
|
+
raise ValueError('Invalid email format')
|
|
183
|
+
return v
|
|
184
|
+
def get_user_from_database_by_username(username: str):
|
|
185
|
+
"""Read a user straight from the database, bypassing the auth provider."""
|
|
186
|
+
try:
|
|
187
|
+
query = '''
|
|
188
|
+
SELECT
|
|
189
|
+
id, username, password_hash, status, role,
|
|
190
|
+
email, phone, full_name, agree_terms,
|
|
191
|
+
created_at, updated_at,
|
|
192
|
+
external_id, auth_source, last_external_sync
|
|
193
|
+
FROM users
|
|
194
|
+
WHERE username = ?
|
|
195
|
+
'''
|
|
196
|
+
return DatabaseManager.execute_sql_one(query, (username,))
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.error(f"Error getting user from database by username {username}: {str(e)}")
|
|
199
|
+
return None
|
|
200
|
+
def self_registration_enabled(environ=None) -> bool:
|
|
201
|
+
"""Whether the registration form is open in this deployment."""
|
|
202
|
+
environ = environ if environ is not None else os.environ
|
|
203
|
+
return str(environ.get(SELF_REGISTRATION_ENV, "")).strip().lower() in (
|
|
204
|
+
"1", "true", "yes", "on")
|
|
205
|
+
class UserProfileUpdate(BaseModel):
|
|
206
|
+
"""What a user may change about themselves."""
|
|
207
|
+
|
|
208
|
+
email: Optional[str] = None
|
|
209
|
+
phone: Optional[str] = None
|
|
210
|
+
full_name: Optional[str] = None
|
|
211
|
+
|
|
212
|
+
@field_validator('email')
|
|
213
|
+
def validate_email(cls, v):
|
|
214
|
+
if v and not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', v):
|
|
215
|
+
raise ValueError('Invalid email format')
|
|
216
|
+
return v
|
|
217
|
+
def update_user_profile(user_id: int, email: str = None, phone: str = None, full_name: str = None):
|
|
218
|
+
"""Update a user's profile."""
|
|
219
|
+
update_fields = []
|
|
220
|
+
params = []
|
|
221
|
+
|
|
222
|
+
if email is not None:
|
|
223
|
+
update_fields.append("email = ?")
|
|
224
|
+
params.append(email)
|
|
225
|
+
|
|
226
|
+
if phone is not None:
|
|
227
|
+
update_fields.append("phone = ?")
|
|
228
|
+
params.append(phone)
|
|
229
|
+
|
|
230
|
+
if full_name is not None:
|
|
231
|
+
update_fields.append("full_name = ?")
|
|
232
|
+
params.append(full_name)
|
|
233
|
+
|
|
234
|
+
if not update_fields:
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
update_fields.append("updated_at = CURRENT_TIMESTAMP")
|
|
238
|
+
params.append(user_id)
|
|
239
|
+
query = f"UPDATE users SET {', '.join(update_fields)} WHERE id = ?"
|
|
240
|
+
|
|
241
|
+
if db_config.is_postgres():
|
|
242
|
+
query = query.replace('?', '%s')
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
result = DatabaseManager.execute_commit_only(query, params)
|
|
246
|
+
return result > 0
|
|
247
|
+
except Exception as e:
|
|
248
|
+
logger.error(f"Error updating user profile: {str(e)}")
|
|
249
|
+
return False
|
|
250
|
+
class UserPasswordUpdate(BaseModel):
|
|
251
|
+
"""A password change, held to the application's own rule."""
|
|
252
|
+
|
|
253
|
+
new_password: str = Field(..., min_length=6, description="New password, at least 6 characters")
|
|
254
|
+
async def notify_account_blocked(manager, user_id: int, admin: dict) -> None:
|
|
255
|
+
"""Let every plugin that stops something for a blocked account do so."""
|
|
256
|
+
for plugin_id, plugin in manager.plugins.items():
|
|
257
|
+
handler = (plugin.get_handlers() or {}).get("on_account_blocked") \
|
|
258
|
+
if getattr(plugin, "initialized", False) else None
|
|
259
|
+
if not handler:
|
|
260
|
+
continue
|
|
261
|
+
try:
|
|
262
|
+
await handler(user_id, admin)
|
|
263
|
+
except Exception as e:
|
|
264
|
+
logger.error(f"Plugin {plugin_id} failed to stop what blocked account {user_id} runs: {e}")
|
|
265
|
+
|
|
266
|
+
async def refresh_access_token(current_user: dict, request: Request = None,
|
|
267
|
+
response: Response = None):
|
|
268
|
+
"""Exchange a still-valid token for a new one with a full lifetime.
|
|
269
|
+
|
|
270
|
+
Authorised by the token being exchanged and nothing else: the holder has
|
|
271
|
+
already proved its right by presenting it, and the host agent keeps no
|
|
272
|
+
password in memory.
|
|
273
|
+
|
|
274
|
+
An expired token cannot be exchanged -- get_current_user rejects it before
|
|
275
|
+
this handler runs -- because renewing one would make the expiry mean
|
|
276
|
+
nothing: a token issued once would live forever. A blocked account is
|
|
277
|
+
refused for the same reason it is refused everywhere else: status is
|
|
278
|
+
checked on every request, not only at login.
|
|
279
|
+
"""
|
|
280
|
+
from keepup.auth import session_lifetime
|
|
281
|
+
|
|
282
|
+
session_started_at = current_user.get("session_started_at")
|
|
283
|
+
if not session_lifetime.is_renewable(session_started_at):
|
|
284
|
+
raise HTTPException(
|
|
285
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
286
|
+
detail="This session has run its course. Sign in again.",
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# Carried forward, not restarted: the window counts from the login, and the
|
|
290
|
+
# session stays the one a logout will revoke. A token from before sessions
|
|
291
|
+
# were recorded gets one here.
|
|
292
|
+
issued = issue_session_token(current_user["id"], current_user["username"],
|
|
293
|
+
sid=current_user.get("session_id"),
|
|
294
|
+
session_started_at=session_started_at)
|
|
295
|
+
if request is not None and response is not None and _authenticated_by_cookie(request):
|
|
296
|
+
panel_session.set_cookies(response, request, issued["access_token"], issued["expires_in"])
|
|
297
|
+
|
|
298
|
+
logger.info(f"Refreshed access token for {current_user['username']}")
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
"access_token": issued["access_token"],
|
|
302
|
+
"token_type": "bearer",
|
|
303
|
+
"expires_in": issued["expires_in"],
|
|
304
|
+
"success": True
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def register(user: UserCreate):
|
|
309
|
+
if not self_registration_enabled():
|
|
310
|
+
# Not "your account is blocked", which is what a person used to meet
|
|
311
|
+
# after filling this in: that answer names a state they cannot act on,
|
|
312
|
+
# while this one names the way in.
|
|
313
|
+
raise HTTPException(
|
|
314
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
315
|
+
detail="Registration is by invitation. Ask our Telegram bot for an "
|
|
316
|
+
"invitation link, and it will let you set your own password.",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# The same password rule as the invitation path. Two ways into one system
|
|
320
|
+
# cannot hold a password to two different standards -- and this way held it
|
|
321
|
+
# to none at all, while an invitation asked for ten characters.
|
|
322
|
+
problem = password_rule(user.password, user.username) if password_rule else None
|
|
323
|
+
if problem:
|
|
324
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=problem)
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
user_id = await create_user(
|
|
328
|
+
username=user.username,
|
|
329
|
+
password=user.password,
|
|
330
|
+
email=user.email,
|
|
331
|
+
phone=user.phone,
|
|
332
|
+
full_name=user.full_name,
|
|
333
|
+
agree_terms=user.agree_terms
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
"success": True,
|
|
338
|
+
"message": "User registered successfully. Please wait for administrator activation.",
|
|
339
|
+
"user_id": user_id
|
|
340
|
+
}
|
|
341
|
+
except HTTPException as e:
|
|
342
|
+
raise e
|
|
343
|
+
except Exception as e:
|
|
344
|
+
logger.error(f"Error in user registration: {str(e)}")
|
|
345
|
+
raise HTTPException(
|
|
346
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
347
|
+
detail="Error registering user"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
#: Set by the application through configure(): what else the sign-in screen
|
|
352
|
+
#: needs. The framework has no terms and no password-reset address of its own.
|
|
353
|
+
application_public_config = None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def configure_public_config(supplier):
|
|
357
|
+
global application_public_config
|
|
358
|
+
application_public_config = supplier
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def register_auth_routes(app, manager):
|
|
362
|
+
"""Register sign-in, session and user management on the application.
|
|
363
|
+
|
|
364
|
+
``manager`` is the plugin manager: blocking an account has to reach the
|
|
365
|
+
plugins that run things for that account.
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
@app.get("/api/public/config")
|
|
369
|
+
async def public_config_endpoint():
|
|
370
|
+
"""What the sign-in screen needs before anyone has signed in.
|
|
371
|
+
|
|
372
|
+
The framework answers what it knows itself and lets the application
|
|
373
|
+
fill in the rest. Before task keepup-27 the whole endpoint belonged to
|
|
374
|
+
one application, and the panel shell -- which the framework ships --
|
|
375
|
+
called it in every application, including the two that never had it.
|
|
376
|
+
"""
|
|
377
|
+
config = {"self_registration": self_registration_enabled()}
|
|
378
|
+
if application_public_config is None:
|
|
379
|
+
return config
|
|
380
|
+
try:
|
|
381
|
+
extra = application_public_config()
|
|
382
|
+
if inspect.isawaitable(extra):
|
|
383
|
+
extra = await extra
|
|
384
|
+
config.update(extra or {})
|
|
385
|
+
except Exception as error:
|
|
386
|
+
# The sign-in screen must come up: without the terms the register
|
|
387
|
+
# form shows none, which is better than no screen at all.
|
|
388
|
+
logger.warning(f"Could not read the application's public config: {error}")
|
|
389
|
+
return config
|
|
390
|
+
|
|
391
|
+
@app.post("/api/auth/register", response_model=dict)
|
|
392
|
+
async def register_endpoint(user: UserCreate):
|
|
393
|
+
return await register(user)
|
|
394
|
+
|
|
395
|
+
@app.post("/api/auth/login", response_model=Token)
|
|
396
|
+
async def login(login_data: LoginRequest, request: Request, response: Response):
|
|
397
|
+
from keepup.auth import login_throttle
|
|
398
|
+
|
|
399
|
+
# Guessing a password had no cost: every attempt was answered as fast as the
|
|
400
|
+
# first. The count is per name and lives in the database, because the next
|
|
401
|
+
# attempt may well be served by another replica.
|
|
402
|
+
locked_until = login_throttle.locked_until(login_data.username)
|
|
403
|
+
if locked_until:
|
|
404
|
+
# Deliberately says nothing about whether the name exists or the password
|
|
405
|
+
# was right -- answering that here would turn the lockout into an oracle.
|
|
406
|
+
raise HTTPException(
|
|
407
|
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
408
|
+
detail="Too many sign-in attempts. Try again later.",
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
user = await get_user_by_username_async(login_data.username)
|
|
412
|
+
if not user or not await authenticate(login_data.username, login_data.password):
|
|
413
|
+
login_throttle.record_failure(login_data.username)
|
|
414
|
+
raise HTTPException(
|
|
415
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
416
|
+
detail="Incorrect username or password",
|
|
417
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
login_throttle.record_success(login_data.username)
|
|
421
|
+
|
|
422
|
+
if user["status"] != "active":
|
|
423
|
+
raise HTTPException(
|
|
424
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
425
|
+
detail="User account is blocked. Please contact administrator.",
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
issued = issue_session_token(user["id"], user["username"])
|
|
429
|
+
access_token = issued["access_token"]
|
|
430
|
+
# The panel keeps the session in a cookie its scripts cannot read; the token
|
|
431
|
+
# in the body stays for the host agent and scripts, which send it as Bearer.
|
|
432
|
+
panel_session.set_cookies(response, request, access_token, issued["expires_in"])
|
|
433
|
+
|
|
434
|
+
if record_login is not None:
|
|
435
|
+
await record_login(user)
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
"access_token": access_token,
|
|
439
|
+
"token_type": "bearer",
|
|
440
|
+
# How long the token has, so the holder need not decode the JWT to
|
|
441
|
+
# learn what the server already knows. The agent schedules its renewal
|
|
442
|
+
# off this: an expired token cannot buy a websocket key, and the host
|
|
443
|
+
# then drops out of the system with the machine still running.
|
|
444
|
+
"expires_in": issued["expires_in"],
|
|
445
|
+
"success": True
|
|
446
|
+
}
|
|
447
|
+
@app.post("/api/auth/refresh")
|
|
448
|
+
async def refresh_access_token_endpoint(current_user: dict = Depends(get_current_user),
|
|
449
|
+
request: Request = None, response: Response = None):
|
|
450
|
+
"""Exchange a still-valid token for a new one with a full lifetime."""
|
|
451
|
+
return await refresh_access_token(current_user, request, response)
|
|
452
|
+
|
|
453
|
+
@app.post("/api/auth/logout")
|
|
454
|
+
async def logout(request: Request, response: Response):
|
|
455
|
+
"""End this session on the server and forget the cookies.
|
|
456
|
+
|
|
457
|
+
Answers 200 even without a live session: the browser has to be able to clear
|
|
458
|
+
a cookie whose session is already gone. A POST, with the CSRF check of any
|
|
459
|
+
cookie request -- a logout by GET could be triggered by an image elsewhere.
|
|
460
|
+
"""
|
|
461
|
+
token = panel_session.token_from_request(request, _bearer_of(request))
|
|
462
|
+
revoked = False
|
|
463
|
+
if token:
|
|
464
|
+
try:
|
|
465
|
+
payload = jwt.decode(token, resolve_signing_key(), algorithms=[ALGORITHM])
|
|
466
|
+
sid = payload.get(panel_session.SESSION_CLAIM)
|
|
467
|
+
if sid:
|
|
468
|
+
revoked = panel_session.revoke(sid, panel_session.REASON_LOGOUT) > 0
|
|
469
|
+
except JWTError:
|
|
470
|
+
pass
|
|
471
|
+
panel_session.clear_cookies(response)
|
|
472
|
+
return {"success": True, "revoked": revoked}
|
|
473
|
+
@app.post("/api/auth/session")
|
|
474
|
+
async def exchange_token_for_session(request: Request, response: Response,
|
|
475
|
+
current_user: dict = Depends(get_current_user)):
|
|
476
|
+
"""Move a session the page holds as a token into the cookie.
|
|
477
|
+
|
|
478
|
+
For the invitation page, whose session is issued by a plugin that cannot set
|
|
479
|
+
a cookie, and for a panel tab still holding a token from before the cookie.
|
|
480
|
+
The token is not returned: the point is that the page stops holding one.
|
|
481
|
+
"""
|
|
482
|
+
issued = issue_session_token(current_user["id"], current_user["username"],
|
|
483
|
+
sid=current_user.get("session_id"),
|
|
484
|
+
session_started_at=current_user.get("session_started_at"))
|
|
485
|
+
panel_session.set_cookies(response, request, issued["access_token"], issued["expires_in"])
|
|
486
|
+
return {"success": True, "expires_in": issued["expires_in"]}
|
|
487
|
+
@app.get("/api/auth/verify")
|
|
488
|
+
async def verify_token(current_user: dict = Depends(get_current_user)):
|
|
489
|
+
"""Validate the caller's token."""
|
|
490
|
+
return {
|
|
491
|
+
"success": True,
|
|
492
|
+
"user": {
|
|
493
|
+
"id": current_user["id"],
|
|
494
|
+
"username": current_user["username"],
|
|
495
|
+
"status": current_user["status"],
|
|
496
|
+
"role": current_user["role"],
|
|
497
|
+
"created_at": current_user["created_at"]
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
@app.put("/api/auth/profile", response_model=dict)
|
|
501
|
+
async def update_profile(
|
|
502
|
+
profile_data: UserProfileUpdate,
|
|
503
|
+
current_user: dict = Depends(get_current_user)
|
|
504
|
+
):
|
|
505
|
+
"""Update the current user's profile."""
|
|
506
|
+
success = update_user_profile(
|
|
507
|
+
user_id=current_user["id"],
|
|
508
|
+
email=profile_data.email,
|
|
509
|
+
phone=profile_data.phone,
|
|
510
|
+
full_name=profile_data.full_name
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
if success:
|
|
514
|
+
return {"success": True, "message": "Profile updated successfully"}
|
|
515
|
+
else:
|
|
516
|
+
raise HTTPException(status_code=500, detail="Error updating profile")
|
|
517
|
+
@app.get("/api/auth/me")
|
|
518
|
+
async def get_me(current_user: dict = Depends(get_current_user)):
|
|
519
|
+
"""Return the current user."""
|
|
520
|
+
return {
|
|
521
|
+
"id": current_user["id"],
|
|
522
|
+
"username": current_user["username"],
|
|
523
|
+
"email": current_user.get("email"),
|
|
524
|
+
"phone": current_user.get("phone"),
|
|
525
|
+
"full_name": current_user.get("full_name"),
|
|
526
|
+
"status": current_user["status"],
|
|
527
|
+
"role": current_user["role"],
|
|
528
|
+
"created_at": current_user["created_at"],
|
|
529
|
+
"updated_at": current_user.get("updated_at"),
|
|
530
|
+
# Whether this person has documents left to accept. The framework
|
|
531
|
+
# has no documents of its own and does not serve them -- an
|
|
532
|
+
# application that has them registers its own routes. What the
|
|
533
|
+
# framework can say is whether there is anything to ask for, and
|
|
534
|
+
# that is what keeps the panel shell from calling a route that
|
|
535
|
+
# exists in one application out of three.
|
|
536
|
+
"documents_pending": _documents_pending(current_user),
|
|
537
|
+
}
|
|
538
|
+
@app.put("/api/admin/users/{user_id}/password", response_model=dict)
|
|
539
|
+
async def admin_change_user_password(
|
|
540
|
+
user_id: int,
|
|
541
|
+
password_data: UserPasswordUpdate,
|
|
542
|
+
admin: dict = Depends(get_current_admin)
|
|
543
|
+
):
|
|
544
|
+
"""Change a user's password as an administrator.
|
|
545
|
+
|
|
546
|
+
Requires:
|
|
547
|
+
- administrator rights
|
|
548
|
+
- a new password of at least 6 characters.
|
|
549
|
+
"""
|
|
550
|
+
try:
|
|
551
|
+
user = get_user_by_id(user_id)
|
|
552
|
+
if not user:
|
|
553
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
554
|
+
|
|
555
|
+
# The application's rule, the same one registration is held to. It
|
|
556
|
+
# used to apply on one of the two paths that set a password: a
|
|
557
|
+
# deployment asking for twelve characters got them at registration
|
|
558
|
+
# and lost them here, without a word (task keepup-14).
|
|
559
|
+
problem = (password_rule(password_data.new_password, user['username'])
|
|
560
|
+
if password_rule else None)
|
|
561
|
+
if problem:
|
|
562
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=problem)
|
|
563
|
+
|
|
564
|
+
new_password_hash = bcrypt.hashpw(
|
|
565
|
+
password_data.new_password.encode('utf-8'),
|
|
566
|
+
bcrypt.gensalt()
|
|
567
|
+
).decode('utf-8')
|
|
568
|
+
|
|
569
|
+
query = '''
|
|
570
|
+
UPDATE users
|
|
571
|
+
SET password_hash = ?
|
|
572
|
+
WHERE id = ?
|
|
573
|
+
'''
|
|
574
|
+
|
|
575
|
+
if db_config.is_postgres():
|
|
576
|
+
query = query.replace('?', '%s')
|
|
577
|
+
|
|
578
|
+
conn = DatabaseManager.get_connection()
|
|
579
|
+
cursor = conn.cursor()
|
|
580
|
+
|
|
581
|
+
try:
|
|
582
|
+
cursor.execute(query, (new_password_hash, user_id))
|
|
583
|
+
conn.commit()
|
|
584
|
+
# A new password that leaves the old sessions working changes nothing
|
|
585
|
+
# for whoever took the old one.
|
|
586
|
+
sessions_revoked = panel_session.revoke_all(user_id, panel_session.REASON_PASSWORD_CHANGED)
|
|
587
|
+
logger.info(
|
|
588
|
+
f"Administrator {admin['username']} changed the password of "
|
|
589
|
+
f"{user['username']} (ID: {user_id})"
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
DatabaseManager.execute_commit_only('''
|
|
593
|
+
INSERT INTO system_metrics (metric_name, metric_value, app_instance, tags)
|
|
594
|
+
VALUES (?, ?, ?, ?)
|
|
595
|
+
''', (
|
|
596
|
+
"admin_password_change",
|
|
597
|
+
1,
|
|
598
|
+
get_instance_id(),
|
|
599
|
+
f"admin:{admin['username']},target_user:{user['username']},user_id:{user_id}"
|
|
600
|
+
))
|
|
601
|
+
|
|
602
|
+
return {
|
|
603
|
+
"success": True,
|
|
604
|
+
"message": f"Password for user {user['username']} changed successfully",
|
|
605
|
+
"sessions_revoked": sessions_revoked,
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
finally:
|
|
609
|
+
cursor.close()
|
|
610
|
+
conn.close()
|
|
611
|
+
|
|
612
|
+
except HTTPException:
|
|
613
|
+
raise
|
|
614
|
+
except Exception as e:
|
|
615
|
+
logger.error(f"Could not change the password of user {user_id}: {str(e)}")
|
|
616
|
+
raise HTTPException(
|
|
617
|
+
status_code=500,
|
|
618
|
+
detail="Error changing password"
|
|
619
|
+
)
|
|
620
|
+
@app.get("/api/admin/users", response_model=List[UserResponse])
|
|
621
|
+
async def get_all_users_endpoint(admin: dict = Depends(get_current_admin)):
|
|
622
|
+
users = get_all_users()
|
|
623
|
+
return users
|
|
624
|
+
@app.get("/api/admin/users/{user_id}", response_model=UserResponse)
|
|
625
|
+
async def get_user_endpoint(user_id: int, admin: dict = Depends(get_current_admin)):
|
|
626
|
+
user = get_user_by_id(user_id)
|
|
627
|
+
if not user:
|
|
628
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
629
|
+
return dict(user)
|
|
630
|
+
@app.patch("/api/admin/users/{user_id}", response_model=dict)
|
|
631
|
+
async def update_user_endpoint(
|
|
632
|
+
user_id: int,
|
|
633
|
+
user_data: UserUpdateRequest,
|
|
634
|
+
admin: dict = Depends(get_current_admin)
|
|
635
|
+
):
|
|
636
|
+
user = get_user_by_id(user_id)
|
|
637
|
+
if not user:
|
|
638
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
639
|
+
if user_data.status and user_data.status != "active" and int(admin["id"]) == int(user_id):
|
|
640
|
+
raise HTTPException(status_code=400, detail="An administrator cannot block their own account")
|
|
641
|
+
|
|
642
|
+
update_user(
|
|
643
|
+
user_id,
|
|
644
|
+
user_data.status,
|
|
645
|
+
user_data.role,
|
|
646
|
+
user_data.email,
|
|
647
|
+
user_data.phone,
|
|
648
|
+
user_data.full_name
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
# Leaving "active" here is a block too, and must not skip what a block stops.
|
|
652
|
+
if user.get("status") == "active" and user_data.status and user_data.status != "active":
|
|
653
|
+
# The sessions go first. An HTTP request is refused anyway -- the
|
|
654
|
+
# status is read on every one -- but a WebSocket authenticates once
|
|
655
|
+
# at the handshake and then runs, so a blocked account kept whatever
|
|
656
|
+
# socket it already had open (task keepup-14).
|
|
657
|
+
panel_session.revoke_all(int(user_id), panel_session.REASON_ACCOUNT_BLOCKED)
|
|
658
|
+
await notify_account_blocked(manager, int(user_id), admin)
|
|
659
|
+
|
|
660
|
+
return {"success": True, "message": "User updated successfully"}
|