keepup-admin 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- keepup/THIRD-PARTY.md +44 -0
- keepup/__init__.py +41 -0
- keepup/api_versions.py +100 -0
- keepup/audit.py +499 -0
- keepup/auth/__init__.py +7 -0
- keepup/auth/config.py +128 -0
- keepup/auth/dependencies.py +485 -0
- keepup/auth/dto/__init__.py +0 -0
- keepup/auth/dto/token.py +31 -0
- keepup/auth/factory.py +28 -0
- keepup/auth/login_throttle.py +151 -0
- keepup/auth/oidc.py +245 -0
- keepup/auth/oidc_policy.py +111 -0
- keepup/auth/oidc_routes.py +288 -0
- keepup/auth/panel_session.py +220 -0
- keepup/auth/permissions.py +59 -0
- keepup/auth/providers/__init__.py +0 -0
- keepup/auth/providers/base.py +168 -0
- keepup/auth/providers/local.py +180 -0
- keepup/auth/routes.py +660 -0
- keepup/auth/seed_accounts.py +322 -0
- keepup/auth/session_lifetime.py +104 -0
- keepup/auth/signing_key.py +138 -0
- keepup/auth/websocket.py +86 -0
- keepup/cluster.py +634 -0
- keepup/db.py +663 -0
- keepup/events.py +764 -0
- keepup/factory.py +484 -0
- keepup/instance.py +46 -0
- keepup/integrations.py +260 -0
- keepup/locks.py +412 -0
- keepup/logging_setup.py +690 -0
- keepup/metrics.py +818 -0
- keepup/metrics_retention.py +376 -0
- keepup/modules.py +572 -0
- keepup/notification_bus.py +355 -0
- keepup/plugins/__init__.py +9 -0
- keepup/plugins/admin.py +246 -0
- keepup/plugins/base.py +234 -0
- keepup/plugins/enablement.py +184 -0
- keepup/plugins/registry.py +171 -0
- keepup/plugins/route_mask.py +338 -0
- keepup/plugins/routes.py +376 -0
- keepup/roles.py +17 -0
- keepup/scheduler.py +93 -0
- keepup/schema.py +295 -0
- keepup/sections.json +104 -0
- keepup/settings.py +184 -0
- keepup/static/css/aos.css +1 -0
- keepup/static/css/main_nebula.css +232 -0
- keepup/static/css/main_new.css +852 -0
- keepup/static/css/tailwind.css +1 -0
- keepup/static/index_nebula.html +293 -0
- keepup/static/index_new.html +286 -0
- keepup/static/js/aos.js +1 -0
- keepup/static/js/feather-icons.js +13 -0
- keepup/static/js/main_new.js +1861 -0
- keepup/static/js/tailwind.js +83 -0
- keepup/static/modules/css/background_tasks.css +233 -0
- keepup/static/modules/css/cluster.css +16 -0
- keepup/static/modules/css/event_manager.css +386 -0
- keepup/static/modules/css/integration_logs.css +33 -0
- keepup/static/modules/css/metrics.css +115 -0
- keepup/static/modules/css/modules.css +189 -0
- keepup/static/modules/css/themes.css +563 -0
- keepup/static/modules/css/users.css +278 -0
- keepup/static/modules/js/background_tasks.js +657 -0
- keepup/static/modules/js/chart.js +14 -0
- keepup/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
- keepup/static/modules/js/cluster.js +363 -0
- keepup/static/modules/js/event_manager.js +979 -0
- keepup/static/modules/js/integration_logs.js +767 -0
- keepup/static/modules/js/metrics.js +908 -0
- keepup/static/modules/js/modules.js +1086 -0
- keepup/static/modules/js/themes.js +653 -0
- keepup/static/modules/js/users.js +784 -0
- keepup/tables.py +302 -0
- keepup/themes.py +496 -0
- keepup/web.py +182 -0
- keepup_admin-0.1.0.dist-info/METADATA +117 -0
- keepup_admin-0.1.0.dist-info/RECORD +86 -0
- keepup_admin-0.1.0.dist-info/WHEEL +5 -0
- keepup_admin-0.1.0.dist-info/licenses/LICENSE +202 -0
- keepup_admin-0.1.0.dist-info/licenses/NOTICE +22 -0
- keepup_admin-0.1.0.dist-info/licenses/THIRD-PARTY.md +44 -0
- keepup_admin-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""The two endpoints of the external sign-in, and what happens between them.
|
|
2
|
+
|
|
3
|
+
``/api/auth/oidc/login`` sends the browser to the provider;
|
|
4
|
+
``/api/auth/oidc/callback`` receives it back, checks everything
|
|
5
|
+
(:mod:`keepup.auth.oidc`), decides whether somebody unknown may have an account
|
|
6
|
+
(:mod:`keepup.auth.oidc_policy`), and ends by issuing the ordinary session of
|
|
7
|
+
this application -- the same token and the same cookie a local sign-in gives,
|
|
8
|
+
so the panel, the renewal and the logout know nothing about how the person got
|
|
9
|
+
in.
|
|
10
|
+
|
|
11
|
+
Registered only when the application configured a provider. Without one the
|
|
12
|
+
paths do not exist: the possibility is off, not broken, and a 404 there is the
|
|
13
|
+
truthful answer.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
from fastapi import HTTPException, Request, Response, status
|
|
20
|
+
from fastapi.responses import RedirectResponse
|
|
21
|
+
|
|
22
|
+
from keepup.auth import oidc, oidc_policy, panel_session
|
|
23
|
+
from keepup.auth.dependencies import issue_session_token
|
|
24
|
+
from keepup.db import DatabaseManager, DatabaseManagerV2, db_config
|
|
25
|
+
from keepup.roles import ROLE_ADMIN, ROLE_CLIENT
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
#: Permissions that make somebody an administrator here. Mapping a claim onto
|
|
30
|
+
#: one of them is how a provider's role becomes a role in this application.
|
|
31
|
+
ADMIN_PERMISSIONS = ("admin", "manage_users", "manage_settings")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _refuse(reason: str) -> HTTPException:
|
|
35
|
+
"""One answer for every failed check; the reason stays in the log.
|
|
36
|
+
|
|
37
|
+
Telling the caller which check failed says which ones they passed, and that
|
|
38
|
+
is a map for whoever is probing.
|
|
39
|
+
"""
|
|
40
|
+
logger.warning(f"OIDC sign-in refused: {reason}")
|
|
41
|
+
return HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=oidc.REFUSAL)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --- the account behind the claims --------------------------------------------
|
|
45
|
+
|
|
46
|
+
def find_account(issuer: str, subject: str) -> Optional[Dict[str, Any]]:
|
|
47
|
+
"""The account tied to this provider's subject, if there is one.
|
|
48
|
+
|
|
49
|
+
Looked up by subject rather than by email: an address at a provider changes
|
|
50
|
+
hands, and matching on it would let a new employee into the account of the
|
|
51
|
+
person whose address they inherited.
|
|
52
|
+
"""
|
|
53
|
+
rows = DatabaseManagerV2.execute(
|
|
54
|
+
"SELECT id, username, status, role FROM users "
|
|
55
|
+
"WHERE auth_source = :source AND external_id = :subject",
|
|
56
|
+
{"source": issuer, "subject": str(subject)},
|
|
57
|
+
)
|
|
58
|
+
return dict(rows[0]) if rows else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def propose_username(claims: Dict[str, Any]) -> str:
|
|
62
|
+
"""A name for the new account, taken from what the provider said."""
|
|
63
|
+
for claim in ("preferred_username", "email", "sub"):
|
|
64
|
+
value = claims.get(claim)
|
|
65
|
+
if value:
|
|
66
|
+
return str(value).strip().lower()
|
|
67
|
+
return "user"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def free_username(proposed: str) -> str:
|
|
71
|
+
"""The proposed name, or the first free variant of it.
|
|
72
|
+
|
|
73
|
+
Names are unique here and come from somewhere else entirely; two providers,
|
|
74
|
+
or two people at one provider, can propose the same one.
|
|
75
|
+
"""
|
|
76
|
+
candidate = proposed
|
|
77
|
+
suffix = 1
|
|
78
|
+
while DatabaseManagerV2.execute_one(
|
|
79
|
+
"SELECT id FROM users WHERE username = :name", {"name": candidate}):
|
|
80
|
+
suffix += 1
|
|
81
|
+
candidate = f"{proposed}-{suffix}"
|
|
82
|
+
return candidate
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def create_account(issuer: str, claims: Dict[str, Any],
|
|
86
|
+
decision: oidc_policy.AccountDecision) -> Dict[str, Any]:
|
|
87
|
+
"""Create the account the application's policy agreed to.
|
|
88
|
+
|
|
89
|
+
The password column is filled with an unusable value rather than left
|
|
90
|
+
empty: this account has no password here, and a blank one is something a
|
|
91
|
+
local sign-in might one day accept.
|
|
92
|
+
"""
|
|
93
|
+
username = free_username(propose_username(claims))
|
|
94
|
+
DatabaseManagerV2.execute_commit(
|
|
95
|
+
"INSERT INTO users (username, password_hash, status, role, email, full_name, "
|
|
96
|
+
"auth_source, external_id, agree_terms) "
|
|
97
|
+
"VALUES (:username, :password_hash, :status, :role, :email, :full_name, "
|
|
98
|
+
":source, :subject, :agree_terms)",
|
|
99
|
+
{
|
|
100
|
+
"username": username,
|
|
101
|
+
"password_hash": "!external",
|
|
102
|
+
"status": decision.status,
|
|
103
|
+
"role": decision.role,
|
|
104
|
+
"email": claims.get("email"),
|
|
105
|
+
"full_name": claims.get("name"),
|
|
106
|
+
"source": issuer,
|
|
107
|
+
"subject": str(claims["sub"]),
|
|
108
|
+
"agree_terms": False,
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
logger.info(f"OIDC account created for {issuer} subject: {username} ({decision.reason})")
|
|
112
|
+
account = find_account(issuer, claims["sub"])
|
|
113
|
+
if account is None:
|
|
114
|
+
raise _refuse("the account was created but cannot be read back")
|
|
115
|
+
return account
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --- roles out of the claims ---------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def external_roles(claims: Dict[str, Any], roles_claim: str) -> List[str]:
|
|
121
|
+
"""The provider's roles, however this provider chose to shape them."""
|
|
122
|
+
value = claims.get(roles_claim)
|
|
123
|
+
if value is None:
|
|
124
|
+
return []
|
|
125
|
+
if isinstance(value, str):
|
|
126
|
+
return [part for part in value.replace(",", " ").split() if part]
|
|
127
|
+
if isinstance(value, (list, tuple)):
|
|
128
|
+
return [str(part) for part in value if part]
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def permissions_for(issuer: str, roles: List[str]) -> Dict[str, bool]:
|
|
133
|
+
"""Map the provider's roles onto permissions here.
|
|
134
|
+
|
|
135
|
+
Through the same table the directory provider used, keyed by the issuer:
|
|
136
|
+
which outside role means what is a property of the deployment, not of the
|
|
137
|
+
code.
|
|
138
|
+
"""
|
|
139
|
+
granted: Dict[str, bool] = {}
|
|
140
|
+
for role in roles:
|
|
141
|
+
rows = DatabaseManagerV2.execute(
|
|
142
|
+
"SELECT internal_permission_name FROM external_role_mappings "
|
|
143
|
+
"WHERE auth_source = :source AND external_role_name = :role",
|
|
144
|
+
{"source": issuer, "role": role},
|
|
145
|
+
)
|
|
146
|
+
for row in rows:
|
|
147
|
+
granted[row["internal_permission_name"]] = True
|
|
148
|
+
return granted
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def apply_permissions(user_id: int, permissions: Dict[str, bool]) -> None:
|
|
152
|
+
"""Write this sign-in's permissions, replacing what was there.
|
|
153
|
+
|
|
154
|
+
Replaced rather than added to: a permission taken away at the provider has
|
|
155
|
+
to disappear here as well, and that is the whole reason a central sign-in
|
|
156
|
+
is worth having.
|
|
157
|
+
"""
|
|
158
|
+
DatabaseManagerV2.execute_commit(
|
|
159
|
+
"DELETE FROM user_permissions WHERE user_id = :user_id", {"user_id": user_id})
|
|
160
|
+
for name, granted in permissions.items():
|
|
161
|
+
DatabaseManagerV2.execute_commit(
|
|
162
|
+
"INSERT INTO user_permissions (user_id, permission_name, granted) "
|
|
163
|
+
"VALUES (:user_id, :name, :granted)",
|
|
164
|
+
{"user_id": user_id, "name": name, "granted": bool(granted)},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def role_from_permissions(permissions: Dict[str, bool], default_role: str) -> str:
|
|
169
|
+
"""An administrator is somebody holding an administrative permission."""
|
|
170
|
+
if any(permissions.get(name) for name in ADMIN_PERMISSIONS):
|
|
171
|
+
return ROLE_ADMIN
|
|
172
|
+
return default_role or ROLE_CLIENT
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def sync_roles(account: Dict[str, Any], issuer: str, claims: Dict[str, Any],
|
|
176
|
+
settings) -> str:
|
|
177
|
+
"""Recompute what this person may do, from what the provider said just now."""
|
|
178
|
+
permissions = permissions_for(issuer, external_roles(claims, settings.roles_claim))
|
|
179
|
+
apply_permissions(account["id"], permissions)
|
|
180
|
+
|
|
181
|
+
role = role_from_permissions(permissions, settings.default_role)
|
|
182
|
+
if role != account.get("role"):
|
|
183
|
+
DatabaseManagerV2.execute_commit(
|
|
184
|
+
"UPDATE users SET role = :role WHERE id = :user_id",
|
|
185
|
+
{"role": role, "user_id": account["id"]},
|
|
186
|
+
)
|
|
187
|
+
logger.info(f"OIDC role for {account['username']}: {account.get('role')} -> {role}")
|
|
188
|
+
return role
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# --- the endpoints -------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def register_oidc_routes(app, settings) -> None:
|
|
194
|
+
"""Register external sign-in, if this application configured a provider."""
|
|
195
|
+
if settings.oidc is None:
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
provider = settings.oidc
|
|
199
|
+
directory = oidc.ProviderDirectory(provider)
|
|
200
|
+
app.state.oidc_directory = directory
|
|
201
|
+
|
|
202
|
+
@app.get("/api/auth/oidc/login", include_in_schema=False)
|
|
203
|
+
async def oidc_login(request: Request):
|
|
204
|
+
"""Send the browser to the provider to have the person identified.
|
|
205
|
+
|
|
206
|
+
Takes the request only to decide whether the flow cookie may be marked
|
|
207
|
+
Secure -- the same question the session cookie asks.
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
metadata = await directory.metadata()
|
|
211
|
+
except Exception as error:
|
|
212
|
+
logger.error(f"OIDC provider unreachable: {error}")
|
|
213
|
+
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
214
|
+
detail="The identity provider is unavailable")
|
|
215
|
+
|
|
216
|
+
state = oidc.secrets.token_urlsafe(16)
|
|
217
|
+
nonce = oidc.secrets.token_urlsafe(16)
|
|
218
|
+
verifier, challenge = oidc.make_pkce_pair()
|
|
219
|
+
|
|
220
|
+
response = RedirectResponse(
|
|
221
|
+
oidc.authorization_url(metadata, provider, state, nonce, challenge),
|
|
222
|
+
status_code=status.HTTP_303_SEE_OTHER,
|
|
223
|
+
)
|
|
224
|
+
response.set_cookie(
|
|
225
|
+
oidc.FLOW_COOKIE,
|
|
226
|
+
oidc.issue_flow_token(state, nonce, verifier),
|
|
227
|
+
max_age=oidc.FLOW_COOKIE_MAX_AGE,
|
|
228
|
+
path="/api/auth/oidc",
|
|
229
|
+
httponly=True,
|
|
230
|
+
samesite="lax",
|
|
231
|
+
# It carries the PKCE verifier, the nonce and the state. This was
|
|
232
|
+
# False unconditionally -- not even asking, the way the session
|
|
233
|
+
# cookie does -- so on an HTTPS deployment it still went out over a
|
|
234
|
+
# forced plain request to the same host (task keepup-14).
|
|
235
|
+
secure=panel_session.is_https(request),
|
|
236
|
+
)
|
|
237
|
+
return response
|
|
238
|
+
|
|
239
|
+
@app.get("/api/auth/oidc/callback", include_in_schema=False)
|
|
240
|
+
async def oidc_callback(request: Request, code: str = None, state: str = None,
|
|
241
|
+
error: str = None):
|
|
242
|
+
"""Receive the person back and, if everything checks out, let them in."""
|
|
243
|
+
if error:
|
|
244
|
+
raise _refuse(f"the provider answered with an error: {error}")
|
|
245
|
+
if not code or not state:
|
|
246
|
+
raise _refuse("the return carries no code or no state")
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
flow = oidc.read_flow_token(request.cookies.get(oidc.FLOW_COOKIE))
|
|
250
|
+
if flow.get("state") != state:
|
|
251
|
+
raise oidc.SignInRefused("the state does not belong to this attempt")
|
|
252
|
+
|
|
253
|
+
tokens = await oidc.exchange_code(directory, provider, code, flow["verifier"])
|
|
254
|
+
claims = await oidc.verify_id_token(directory, provider,
|
|
255
|
+
tokens["id_token"], flow["nonce"])
|
|
256
|
+
except oidc.SignInRefused as refusal:
|
|
257
|
+
raise _refuse(str(refusal))
|
|
258
|
+
except Exception as error:
|
|
259
|
+
raise _refuse(f"the exchange failed: {error}")
|
|
260
|
+
|
|
261
|
+
issuer = (await directory.metadata()).issuer
|
|
262
|
+
account = find_account(issuer, claims["sub"])
|
|
263
|
+
|
|
264
|
+
if account is None:
|
|
265
|
+
decision = oidc_policy.decide(settings.oidc_account_policy, claims)
|
|
266
|
+
if not decision.admit:
|
|
267
|
+
raise _refuse(f"policy refused a new account: {decision.reason}")
|
|
268
|
+
account = create_account(issuer, claims, decision)
|
|
269
|
+
|
|
270
|
+
if account.get("status") != "active":
|
|
271
|
+
# Proving who you are is not the same as being allowed in.
|
|
272
|
+
raise _refuse(f"the account {account['username']} is not active")
|
|
273
|
+
|
|
274
|
+
sync_roles(account, issuer, claims, provider)
|
|
275
|
+
|
|
276
|
+
issued = issue_session_token(account["id"], account["username"])
|
|
277
|
+
response = RedirectResponse(provider.after_login_path,
|
|
278
|
+
status_code=status.HTTP_303_SEE_OTHER)
|
|
279
|
+
panel_session.set_cookies(response, request, issued["access_token"],
|
|
280
|
+
issued["expires_in"])
|
|
281
|
+
# One attempt, one use: the cookie is spent, so a replayed return finds
|
|
282
|
+
# nothing to check itself against.
|
|
283
|
+
response.delete_cookie(oidc.FLOW_COOKIE, path="/api/auth/oidc")
|
|
284
|
+
|
|
285
|
+
logger.info(f"OIDC sign-in: {account['username']} via {issuer}")
|
|
286
|
+
if settings.record_login is not None:
|
|
287
|
+
await settings.record_login(dict(account))
|
|
288
|
+
return response
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""The panel session: a server-side record, carried in an httpOnly cookie.
|
|
2
|
+
|
|
3
|
+
The token used to live in localStorage, where any script on the page could read
|
|
4
|
+
it and take the whole session away with it; logging out only forgot it in that
|
|
5
|
+
one browser, and a copied token stayed good for a day. Now a session is a row,
|
|
6
|
+
the token names it (`sid`), and every request looks the row up -- so a logout, a
|
|
7
|
+
password change or a block takes effect on every replica at once.
|
|
8
|
+
|
|
9
|
+
The browser never sees the token: it sits in `ss_session` (HttpOnly). The panel
|
|
10
|
+
keeps the non-secret marker `cookie` where the token used to be, so the many
|
|
11
|
+
sections that check "is there a token" and send `Bearer <token>` keep working;
|
|
12
|
+
the server reads such a header as no header at all and falls back to the cookie.
|
|
13
|
+
|
|
14
|
+
A cookie is sent by the browser on its own, so a request authenticated by it must
|
|
15
|
+
also prove it came from the panel: the double-submit `ss_csrf` cookie, readable by
|
|
16
|
+
the page, echoed in `X-CSRF-Token`. A real `Authorization: Bearer` (an agent, a
|
|
17
|
+
script) needs no such proof -- another site cannot set that header.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import secrets
|
|
23
|
+
from datetime import datetime, timedelta
|
|
24
|
+
from typing import Optional
|
|
25
|
+
from urllib.parse import urlparse
|
|
26
|
+
|
|
27
|
+
from fastapi import HTTPException, Request
|
|
28
|
+
from sqlalchemy import Column, DateTime, Index, Integer, String, Text
|
|
29
|
+
from starlette import status
|
|
30
|
+
|
|
31
|
+
from keepup import tables
|
|
32
|
+
from keepup.db import DatabaseManagerV2
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
TABLE = "auth_session"
|
|
37
|
+
SESSION_CLAIM = "sid"
|
|
38
|
+
SESSION_COOKIE = "ss_session"
|
|
39
|
+
CSRF_COOKIE = "ss_csrf"
|
|
40
|
+
CSRF_HEADER = "X-CSRF-Token"
|
|
41
|
+
|
|
42
|
+
#: What the panel keeps in localStorage instead of the token, and the values a
|
|
43
|
+
#: section sends when it had nothing there. None of them is a credential.
|
|
44
|
+
BEARER_PLACEHOLDERS = frozenset({"", "cookie", "null", "undefined"})
|
|
45
|
+
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
46
|
+
|
|
47
|
+
REASON_LOGOUT = "logout"
|
|
48
|
+
REASON_PASSWORD_CHANGED = "password_changed"
|
|
49
|
+
REASON_ACCOUNT_BLOCKED = "account_blocked"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
AUTH_SESSION = tables.table(
|
|
53
|
+
TABLE,
|
|
54
|
+
Column("sid", String(64).with_variant(Text(), "sqlite"), primary_key=True, nullable=True),
|
|
55
|
+
Column("user_id", Integer, nullable=False),
|
|
56
|
+
Column("created_at", DateTime, nullable=False),
|
|
57
|
+
Column("expires_at", DateTime, nullable=False),
|
|
58
|
+
Column("revoked_at", DateTime),
|
|
59
|
+
Column("revoked_reason", String(64).with_variant(Text(), "sqlite")),
|
|
60
|
+
Index(f"idx_{TABLE}_user", "user_id"),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def init_table() -> None:
|
|
65
|
+
tables.ensure_tables(AUTH_SESSION)
|
|
66
|
+
# Each start is often enough: a row past its end is only kept for inquiry.
|
|
67
|
+
purge_expired()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --- the record ------------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def open_session(user_id: int, lifetime: timedelta, now: Optional[datetime] = None) -> str:
|
|
73
|
+
now = now or datetime.utcnow()
|
|
74
|
+
sid = secrets.token_urlsafe(32)
|
|
75
|
+
DatabaseManagerV2.execute_commit(
|
|
76
|
+
f"INSERT INTO {TABLE} (sid, user_id, created_at, expires_at) "
|
|
77
|
+
f"VALUES (:sid, :user_id, :now, :expires_at)",
|
|
78
|
+
{"sid": sid, "user_id": user_id, "now": now, "expires_at": now + lifetime})
|
|
79
|
+
return sid
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def extend(sid: str, lifetime: timedelta, now: Optional[datetime] = None) -> None:
|
|
83
|
+
"""A renewal keeps the session and moves its end, like the token it renews."""
|
|
84
|
+
now = now or datetime.utcnow()
|
|
85
|
+
DatabaseManagerV2.execute_commit(
|
|
86
|
+
f"UPDATE {TABLE} SET expires_at = :expires_at WHERE sid = :sid AND revoked_at IS NULL",
|
|
87
|
+
{"sid": sid, "expires_at": now + lifetime})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_active(sid: str, user_id: Optional[int] = None) -> bool:
|
|
91
|
+
row = DatabaseManagerV2.execute_one(
|
|
92
|
+
f"SELECT user_id, revoked_at FROM {TABLE} WHERE sid = :sid", {"sid": sid})
|
|
93
|
+
if not row or row.get("revoked_at") is not None:
|
|
94
|
+
return False
|
|
95
|
+
# A session is somebody's: a token whose name and session disagree was not
|
|
96
|
+
# issued by this server.
|
|
97
|
+
return user_id is None or int(row["user_id"]) == int(user_id)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def revoke(sid: str, reason: str = REASON_LOGOUT) -> int:
|
|
101
|
+
return DatabaseManagerV2.execute_commit(
|
|
102
|
+
f"UPDATE {TABLE} SET revoked_at = :now, revoked_reason = :reason "
|
|
103
|
+
f"WHERE sid = :sid AND revoked_at IS NULL",
|
|
104
|
+
{"sid": sid, "now": datetime.utcnow(), "reason": reason})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def revoke_all(user_id: int, reason: str) -> int:
|
|
108
|
+
return DatabaseManagerV2.execute_commit(
|
|
109
|
+
f"UPDATE {TABLE} SET revoked_at = :now, revoked_reason = :reason "
|
|
110
|
+
f"WHERE user_id = :u AND revoked_at IS NULL",
|
|
111
|
+
{"u": user_id, "now": datetime.utcnow(), "reason": reason})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def purge_expired(now: Optional[datetime] = None, keep: timedelta = timedelta(days=7)) -> int:
|
|
115
|
+
"""Rows past their end serve nothing; a week is kept for whoever investigates."""
|
|
116
|
+
return DatabaseManagerV2.execute_commit(
|
|
117
|
+
f"DELETE FROM {TABLE} WHERE expires_at < :cutoff",
|
|
118
|
+
{"cutoff": (now or datetime.utcnow()) - keep})
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --- where the token comes from --------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def real_bearer(value: Optional[str]) -> Optional[str]:
|
|
124
|
+
"""The bearer token, or None when the header only carries a placeholder."""
|
|
125
|
+
if value is None or value.strip() in BEARER_PLACEHOLDERS:
|
|
126
|
+
return None
|
|
127
|
+
return value.strip()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def token_from_request(request: Request, bearer: Optional[str]) -> Optional[str]:
|
|
131
|
+
"""The token of this request: a real bearer first, the session cookie second.
|
|
132
|
+
|
|
133
|
+
A cookie-authenticated request that changes something must carry the CSRF
|
|
134
|
+
header; without it the answer is 403, not a quiet fall-through to anonymous.
|
|
135
|
+
"""
|
|
136
|
+
token = real_bearer(bearer)
|
|
137
|
+
if token:
|
|
138
|
+
return token
|
|
139
|
+
token = request.cookies.get(SESSION_COOKIE)
|
|
140
|
+
if not token:
|
|
141
|
+
return None
|
|
142
|
+
if request.method.upper() not in SAFE_METHODS and not csrf_matches(request):
|
|
143
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
|
144
|
+
detail="CSRF token missing or invalid")
|
|
145
|
+
return token
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def csrf_matches(request: Request) -> bool:
|
|
149
|
+
expected = request.cookies.get(CSRF_COOKIE)
|
|
150
|
+
presented = request.headers.get(CSRF_HEADER)
|
|
151
|
+
return bool(expected and presented and secrets.compare_digest(expected, presented))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def websocket_token(query_token: Optional[str], cookies) -> Optional[str]:
|
|
155
|
+
"""A websocket has no CSRF header to send; `same_origin` stands in for it."""
|
|
156
|
+
return real_bearer(query_token) or (cookies or {}).get(SESSION_COOKIE)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def same_origin(headers) -> bool:
|
|
160
|
+
"""Whether the page that opened a websocket is served from this server.
|
|
161
|
+
|
|
162
|
+
Compared with the host the request names and with the public address: a proxy
|
|
163
|
+
that does not pass `Host` on still leaves the public address to match.
|
|
164
|
+
"""
|
|
165
|
+
origin = urlparse((headers.get("origin") or "").strip()).netloc.lower()
|
|
166
|
+
if not origin:
|
|
167
|
+
return False
|
|
168
|
+
names = {(headers.get("x-forwarded-host") or "").split(",")[0].strip().lower(),
|
|
169
|
+
(headers.get("host") or "").strip().lower()}
|
|
170
|
+
api_host = (os.environ.get("API_HOST") or "").strip().lower()
|
|
171
|
+
if api_host:
|
|
172
|
+
names.add(urlparse(api_host if "://" in api_host else f"//{api_host}").netloc)
|
|
173
|
+
return origin in names - {""}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --- the cookies -----------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
#: Set on a deployment behind a proxy that terminates TLS and does not say so.
|
|
179
|
+
#: Without it the cookie goes out without Secure and travels over plain HTTP
|
|
180
|
+
#: the first time somebody types the address without the scheme -- and there
|
|
181
|
+
#: was no way to insist (task keepup-14).
|
|
182
|
+
FORCE_SECURE_COOKIES_ENV = "FORCE_SECURE_COOKIES"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def is_https(request: Request) -> bool:
|
|
186
|
+
"""Whether the browser reached us over HTTPS -- the proxy says so, or the socket.
|
|
187
|
+
|
|
188
|
+
Not guessed from API_HOST: a direct http visit on the local network would then
|
|
189
|
+
get a Secure cookie the browser refuses to keep, and never stay signed in.
|
|
190
|
+
A deployment that knows better says so with FORCE_SECURE_COOKIES.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
request: the incoming request.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Whether the cookie may be marked Secure.
|
|
197
|
+
"""
|
|
198
|
+
if os.getenv(FORCE_SECURE_COOKIES_ENV, "false").lower() == "true":
|
|
199
|
+
return True
|
|
200
|
+
forwarded = (request.headers.get("x-forwarded-proto") or "").split(",")[0].strip().lower()
|
|
201
|
+
if forwarded:
|
|
202
|
+
return forwarded == "https"
|
|
203
|
+
return request.url.scheme == "https"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def set_cookies(response, request: Request, token: str, max_age: int) -> str:
|
|
207
|
+
secure = is_https(request)
|
|
208
|
+
response.set_cookie(SESSION_COOKIE, token, max_age=max_age, path="/",
|
|
209
|
+
httponly=True, secure=secure, samesite="lax")
|
|
210
|
+
# Kept across renewals: a panel tab holding the old value would otherwise
|
|
211
|
+
# have its next change refused.
|
|
212
|
+
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(32)
|
|
213
|
+
response.set_cookie(CSRF_COOKIE, csrf, max_age=max_age, path="/",
|
|
214
|
+
httponly=False, secure=secure, samesite="lax")
|
|
215
|
+
return csrf
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def clear_cookies(response) -> None:
|
|
219
|
+
response.delete_cookie(SESSION_COOKIE, path="/")
|
|
220
|
+
response.delete_cookie(CSRF_COOKIE, path="/")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Finer-grained rights than the two roles.
|
|
2
|
+
|
|
3
|
+
`keepup.roles` has ADMIN and CLIENT, which is enough to decide who may reach an
|
|
4
|
+
administrative route and not enough for anything else. A permission is checked
|
|
5
|
+
per action, by decorator or by dependency, so a route can be opened to a
|
|
6
|
+
non-administrator without opening the section around it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from functools import wraps
|
|
10
|
+
from fastapi import HTTPException, status, Depends
|
|
11
|
+
|
|
12
|
+
from keepup.roles import ROLE_ADMIN
|
|
13
|
+
from keepup.auth.dependencies import get_all_users, get_current_user
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def require_permission(permission_name: str):
|
|
17
|
+
"""Decorator checking that the user holds the given permission."""
|
|
18
|
+
|
|
19
|
+
def decorator(func):
|
|
20
|
+
@wraps(func)
|
|
21
|
+
async def wrapper(*args, **kwargs):
|
|
22
|
+
current_user = kwargs.get('current_user')
|
|
23
|
+
|
|
24
|
+
if not current_user:
|
|
25
|
+
for arg in args:
|
|
26
|
+
if isinstance(arg, dict) and 'username' in arg:
|
|
27
|
+
current_user = arg
|
|
28
|
+
break
|
|
29
|
+
|
|
30
|
+
if not current_user:
|
|
31
|
+
raise HTTPException(
|
|
32
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
33
|
+
detail="Authentication required"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
permissions = current_user.get('permissions', {})
|
|
37
|
+
|
|
38
|
+
if not permissions.get(permission_name, False):
|
|
39
|
+
raise HTTPException(
|
|
40
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
41
|
+
detail=f"Permission '{permission_name}' required"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return await func(*args, **kwargs)
|
|
45
|
+
|
|
46
|
+
return wrapper
|
|
47
|
+
|
|
48
|
+
return decorator
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Example of how this is used on an endpoint:
|
|
52
|
+
#@app.get("/api/admin/users")
|
|
53
|
+
@require_permission(ROLE_ADMIN)
|
|
54
|
+
async def get_all_users_endpoint(
|
|
55
|
+
current_user: dict = Depends(get_current_user)
|
|
56
|
+
):
|
|
57
|
+
users = (
|
|
58
|
+
get_all_users())
|
|
59
|
+
return users
|
|
File without changes
|