simple-module-site-lock 0.0.30__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.
- simple_module_site_lock-0.0.30.dist-info/METADATA +148 -0
- simple_module_site_lock-0.0.30.dist-info/RECORD +14 -0
- simple_module_site_lock-0.0.30.dist-info/WHEEL +4 -0
- simple_module_site_lock-0.0.30.dist-info/entry_points.txt +2 -0
- site_lock/__init__.py +0 -0
- site_lock/constants.py +28 -0
- site_lock/middleware.py +177 -0
- site_lock/module.py +47 -0
- site_lock/page.py +55 -0
- site_lock/py.typed +0 -0
- site_lock/rate_limit.py +58 -0
- site_lock/settings.py +56 -0
- site_lock/state.py +34 -0
- site_lock/templates/unlock.html +64 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simple_module_site_lock
|
|
3
|
+
Version: 0.0.30
|
|
4
|
+
Summary: Optional site-wide password gate (staging / pre-launch) for simple_module apps
|
|
5
|
+
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
|
+
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
7
|
+
Project-URL: Issues, https://github.com/antosubash/simple_module_python/issues
|
|
8
|
+
Author-email: Anto Subash <antosubash@live.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: gate,password,simple-module,staging
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: FastAPI
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Requires-Dist: simple-module-auth==0.0.30
|
|
22
|
+
Requires-Dist: simple-module-core==0.0.30
|
|
23
|
+
Requires-Dist: simple-module-hosting==0.0.30
|
|
24
|
+
Requires-Dist: simple-module-settings==0.0.30
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# simple_module_site_lock
|
|
28
|
+
|
|
29
|
+
Optional site-wide password gate for `simple_module` apps — a staging /
|
|
30
|
+
pre-launch door. When enabled, every visitor must enter one shared password
|
|
31
|
+
before they can see anything: not the landing page, not the API, not even
|
|
32
|
+
that a login form exists.
|
|
33
|
+
|
|
34
|
+
**Off by default.** Installing this module changes nothing until an operator
|
|
35
|
+
turns it on.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
Add the package to your host and re-sync — discovery picks it up from the
|
|
40
|
+
`simple_module` entry point, no host code changes:
|
|
41
|
+
|
|
42
|
+
```toml
|
|
43
|
+
# host/pyproject.toml
|
|
44
|
+
dependencies = ["simple_module_site_lock"]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uv sync --all-packages
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or scaffold a new app with it selected: `smpy new --modules site_lock`.
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
1. Sign in as an admin and open **Settings → Modules → SiteLock**.
|
|
56
|
+
2. Set `password` (and optionally `message`), tick `enabled`, and **Save**.
|
|
57
|
+
Saving with a blank password is refused — the error appears on the
|
|
58
|
+
`enabled` field and nothing is persisted.
|
|
59
|
+
3. Anyone without the password now gets the gate page at `/__unlock` on every
|
|
60
|
+
URL. They enter the password once and are returned to where they were
|
|
61
|
+
heading.
|
|
62
|
+
4. To lift the gate, untick `enabled` and save. To rotate the password, set a
|
|
63
|
+
new one — every already-unlocked visitor is logged back out of the gate.
|
|
64
|
+
|
|
65
|
+
Keep your own session alive while you do this: an admin with a live session
|
|
66
|
+
skips the gate, which is what lets you undo a mistake (see
|
|
67
|
+
[Admin bypass](#admin-bypass-and-the-lockout-you-can-still-cause) below).
|
|
68
|
+
|
|
69
|
+
## How it works
|
|
70
|
+
|
|
71
|
+
The module installs a single middleware that runs *before* `AuthMiddleware`.
|
|
72
|
+
That ordering is the whole point — an anonymous visitor gets the gate rather
|
|
73
|
+
than a redirect to the login page, so a locked site never reveals that it has
|
|
74
|
+
one.
|
|
75
|
+
|
|
76
|
+
It achieves that ordering by declaring `depends_on=["Settings", "Auth"]`:
|
|
77
|
+
modules are installed in topological order and Starlette's `add_middleware`
|
|
78
|
+
is LIFO, so sorting after `Auth` makes this middleware wrap outermost.
|
|
79
|
+
|
|
80
|
+
Unlock state lives in the signed session cookie. The stored marker is a
|
|
81
|
+
fingerprint of the current password, so **rotating the password immediately
|
|
82
|
+
invalidates every unlocked session**.
|
|
83
|
+
|
|
84
|
+
## Enabling it
|
|
85
|
+
|
|
86
|
+
Settings → Site Lock:
|
|
87
|
+
|
|
88
|
+
| Field | Default | Meaning |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `enabled` | `false` | Master switch |
|
|
91
|
+
| `password` | `""` | The shared password. Masked in the admin UI |
|
|
92
|
+
| `message` | `""` | Optional line shown on the gate page |
|
|
93
|
+
|
|
94
|
+
Changes apply immediately — no restart. Enabling with a blank password is
|
|
95
|
+
rejected by a validator, so you cannot accidentally gate the site behind the
|
|
96
|
+
empty string.
|
|
97
|
+
|
|
98
|
+
## What stays reachable when locked
|
|
99
|
+
|
|
100
|
+
- `/health` — always. Gating it would fail Kubernetes liveness/readiness
|
|
101
|
+
probes and get the pod killed.
|
|
102
|
+
- `/__unlock` — the gate page itself.
|
|
103
|
+
|
|
104
|
+
Everything else is gated. Requests under `/api/`, and any request carrying an
|
|
105
|
+
`Authorization` header, get `403 {"detail": "Site is locked"}` rather than a
|
|
106
|
+
redirect — a `401` would invite an auth flow that cannot succeed while the
|
|
107
|
+
site is locked. Browser requests get a `302` to the gate.
|
|
108
|
+
|
|
109
|
+
That includes the login API itself, so while the gate is on there is no
|
|
110
|
+
programmatic way in: CI smoke tests, uptime monitors, mobile clients and
|
|
111
|
+
webhook senders all get `403` no matter what credentials they hold. Only a
|
|
112
|
+
browser that has passed the gate (or an admin holding a live session) can
|
|
113
|
+
reach anything. Pause those integrations before enabling the gate, or point
|
|
114
|
+
uptime checks at `/health`, which stays open.
|
|
115
|
+
|
|
116
|
+
## Admin bypass, and the lockout you can still cause
|
|
117
|
+
|
|
118
|
+
A user who already holds a session with the `admin` role skips the gate.
|
|
119
|
+
This is the intended escape hatch: if you enable the gate and mistype the
|
|
120
|
+
password, you are still holding the session that lets you go straight back to
|
|
121
|
+
Settings and fix it.
|
|
122
|
+
|
|
123
|
+
**It only rescues a live session.** If no admin is currently signed in and the
|
|
124
|
+
password has been forgotten, there is no in-app recovery. Clear the override
|
|
125
|
+
directly in the database:
|
|
126
|
+
|
|
127
|
+
```sql
|
|
128
|
+
DELETE FROM settings_setting
|
|
129
|
+
WHERE scope = 'system' AND key = 'site_lock.enabled';
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Then restart the app (or save any setting) so the module re-hydrates.
|
|
133
|
+
|
|
134
|
+
## Brute-force protection
|
|
135
|
+
|
|
136
|
+
The unlock endpoint tracks failures per client IP in memory: 10 failures
|
|
137
|
+
within 5 minutes trigger a 15-minute cooldown returning `429`. These
|
|
138
|
+
thresholds are module constants, not settings — they are the only defence on
|
|
139
|
+
a single shared secret, so they are not an operator-tunable surface.
|
|
140
|
+
|
|
141
|
+
The limiter is process-local, which is adequate for the single-process
|
|
142
|
+
staging deployments this module targets.
|
|
143
|
+
|
|
144
|
+
## What this is not
|
|
145
|
+
|
|
146
|
+
This is not user authentication or authorisation — that is what the `auth`,
|
|
147
|
+
`users`, and `permissions` modules are for. The site lock is one shared
|
|
148
|
+
secret in front of everything, with no notion of identity.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
site_lock/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
site_lock/constants.py,sha256=WMB-tV3zX--wIFIXqrx1RIlGUIKQtxA7uEyxCNlzNZs,908
|
|
3
|
+
site_lock/middleware.py,sha256=lTfmhJ_OFujjUCErbeJT9KCAH4AlHYvDmhA3nuBtUJ8,6295
|
|
4
|
+
site_lock/module.py,sha256=2ogUpEb4rcfVTZmSQdBg2157lefMnbw-nozc0OzOVPA,1500
|
|
5
|
+
site_lock/page.py,sha256=rQLPhgp7YNXDX2dcCldDSjxsDyLe7_b4_9OazieL7Vg,1672
|
|
6
|
+
site_lock/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
site_lock/rate_limit.py,sha256=IuFvZ1BcBBW1uJ0dfvIgZPdN8miza7YIhlB0Q0BLvvE,1860
|
|
8
|
+
site_lock/settings.py,sha256=kCGyrqOy_6xh895SV52PwWpMvZaGXyEW2P-mEzO2Z6k,2300
|
|
9
|
+
site_lock/state.py,sha256=iVir-NL6d2v2SvZhecWHYVoMRcH24nfUrK-dlSsvS_A,950
|
|
10
|
+
site_lock/templates/unlock.html,sha256=cg1WxGCtVyK9DKg3xsVDvX6KXE8R40NXAcDA79cSCAc,2494
|
|
11
|
+
simple_module_site_lock-0.0.30.dist-info/METADATA,sha256=3qu7YiaK8g1Wz7SVDCjRvJcvBA2JVnPIstf8y8xljy0,5925
|
|
12
|
+
simple_module_site_lock-0.0.30.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
13
|
+
simple_module_site_lock-0.0.30.dist-info/entry_points.txt,sha256=WlObRbz5zuypwJ5TzAnDq_wPIcR0yj2FQLzm6aNNnnw,60
|
|
14
|
+
simple_module_site_lock-0.0.30.dist-info/RECORD,,
|
site_lock/__init__.py
ADDED
|
File without changes
|
site_lock/constants.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Every literal the site_lock module depends on."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
MODULE_NAME = "SiteLock"
|
|
6
|
+
MODULE_PACKAGE = "site_lock"
|
|
7
|
+
|
|
8
|
+
# Dependencies — names must match the other modules' ``ModuleMeta.name``.
|
|
9
|
+
MODULE_SETTINGS = "Settings"
|
|
10
|
+
MODULE_AUTH = "Auth"
|
|
11
|
+
|
|
12
|
+
# The gate endpoint. Double-underscore prefix keeps it clear of app routes.
|
|
13
|
+
UNLOCK_PATH = "/__unlock"
|
|
14
|
+
SESSION_KEY = "site_lock"
|
|
15
|
+
|
|
16
|
+
# Never gated: Kubernetes liveness/readiness probes must always succeed.
|
|
17
|
+
HEALTH_PREFIX = "/health"
|
|
18
|
+
API_PREFIX = "/api/"
|
|
19
|
+
|
|
20
|
+
# Duplicated rather than imported from ``users`` — site_lock must also work
|
|
21
|
+
# under the ``keycloak`` provider, which has no dependency on ``users``.
|
|
22
|
+
ADMIN_ROLE = "admin"
|
|
23
|
+
|
|
24
|
+
# Brute-force limits. Deliberately NOT settings fields: they are the only
|
|
25
|
+
# defence on a single shared secret and are not an operator-tunable surface.
|
|
26
|
+
MAX_FAILURES = 10
|
|
27
|
+
WINDOW_SECONDS = 300
|
|
28
|
+
COOLDOWN_SECONDS = 900
|
site_lock/middleware.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Site-wide password gate.
|
|
2
|
+
|
|
3
|
+
Runs outermost among module middleware — the module sorts after ``Auth``, and
|
|
4
|
+
Starlette's ``add_middleware`` is LIFO — so this executes *before*
|
|
5
|
+
``AuthMiddleware``. That ordering is the whole point: an anonymous visitor
|
|
6
|
+
sees the gate instead of being redirected to the login page, so a locked site
|
|
7
|
+
never reveals that a login form exists.
|
|
8
|
+
|
|
9
|
+
``SessionMiddleware`` sits outside this one, so ``scope["session"]`` is
|
|
10
|
+
readable here and writes are persisted on the way out.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import logging
|
|
17
|
+
import secrets
|
|
18
|
+
from urllib.parse import quote
|
|
19
|
+
|
|
20
|
+
from starlette.requests import Request
|
|
21
|
+
from starlette.responses import (
|
|
22
|
+
HTMLResponse,
|
|
23
|
+
JSONResponse,
|
|
24
|
+
RedirectResponse,
|
|
25
|
+
Response,
|
|
26
|
+
)
|
|
27
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
28
|
+
|
|
29
|
+
from site_lock import constants as c
|
|
30
|
+
from site_lock.page import render_unlock_page, safe_next
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
_NO_STORE = {"Cache-Control": "no-store"}
|
|
35
|
+
_ERR_WRONG = "Incorrect password."
|
|
36
|
+
_ERR_THROTTLED = "Too many attempts. Try again later."
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def password_fingerprint(password: str) -> str:
|
|
40
|
+
"""Short digest of the active password, stored in the session.
|
|
41
|
+
|
|
42
|
+
Comparing this on each request is what makes a password rotation
|
|
43
|
+
invalidate every previously-unlocked session.
|
|
44
|
+
"""
|
|
45
|
+
return hashlib.sha256(password.encode("utf-8")).hexdigest()[:16]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SiteLockMiddleware:
|
|
49
|
+
"""Gate every request behind a single shared password."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
52
|
+
self.app = app
|
|
53
|
+
|
|
54
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
55
|
+
if scope["type"] != "http":
|
|
56
|
+
await self.app(scope, receive, send)
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
state = scope["app"].state.site_lock
|
|
60
|
+
settings = state.settings
|
|
61
|
+
|
|
62
|
+
# Default-off fast path: one attribute read, no Request construction.
|
|
63
|
+
if not settings.enabled:
|
|
64
|
+
await self.app(scope, receive, send)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
path: str = scope["path"]
|
|
68
|
+
|
|
69
|
+
# Kubernetes probes must never be gated, or the pod gets killed.
|
|
70
|
+
if path.startswith(c.HEALTH_PREFIX):
|
|
71
|
+
await self.app(scope, receive, send)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
fingerprint = password_fingerprint(settings.password)
|
|
75
|
+
session = scope.get("session")
|
|
76
|
+
|
|
77
|
+
if path == c.UNLOCK_PATH:
|
|
78
|
+
response = await self._unlock(scope, receive, state, fingerprint, session)
|
|
79
|
+
await response(scope, receive, send)
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
if session is not None and session.get(c.SESSION_KEY) == fingerprint:
|
|
83
|
+
await self.app(scope, receive, send)
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
if await self._is_admin(scope):
|
|
87
|
+
# Stamp the marker so the provider lookup costs once per session
|
|
88
|
+
# rather than once per request.
|
|
89
|
+
if session is not None:
|
|
90
|
+
session[c.SESSION_KEY] = fingerprint
|
|
91
|
+
await self.app(scope, receive, send)
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
await self._gate(scope, receive, send, path)
|
|
95
|
+
|
|
96
|
+
async def _gate(self, scope: Scope, receive: Receive, send: Send, path: str) -> None:
|
|
97
|
+
has_auth_header = any(k == b"authorization" for k, _ in scope.get("headers", ()))
|
|
98
|
+
if path.startswith(c.API_PREFIX) or has_auth_header:
|
|
99
|
+
# 403 rather than 401: a 401 would invite an auth flow that cannot
|
|
100
|
+
# succeed while the site is locked.
|
|
101
|
+
response: Response = JSONResponse(
|
|
102
|
+
{"detail": "Site is locked"}, status_code=403, headers=_NO_STORE
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
target = f"{c.UNLOCK_PATH}?next={quote(safe_next(path), safe='/')}"
|
|
106
|
+
response = RedirectResponse(target, status_code=302, headers=_NO_STORE)
|
|
107
|
+
await response(scope, receive, send)
|
|
108
|
+
|
|
109
|
+
async def _unlock(
|
|
110
|
+
self,
|
|
111
|
+
scope: Scope,
|
|
112
|
+
receive: Receive,
|
|
113
|
+
state,
|
|
114
|
+
fingerprint: str,
|
|
115
|
+
session,
|
|
116
|
+
) -> Response:
|
|
117
|
+
method: str = scope.get("method", "GET")
|
|
118
|
+
settings = state.settings
|
|
119
|
+
request = Request(scope, receive)
|
|
120
|
+
|
|
121
|
+
if method == "GET":
|
|
122
|
+
return HTMLResponse(
|
|
123
|
+
render_unlock_page(
|
|
124
|
+
message=settings.message,
|
|
125
|
+
next_url=request.query_params.get("next", "/"),
|
|
126
|
+
),
|
|
127
|
+
headers=_NO_STORE,
|
|
128
|
+
)
|
|
129
|
+
if method != "POST":
|
|
130
|
+
return Response(status_code=405, headers=_NO_STORE)
|
|
131
|
+
|
|
132
|
+
client = scope.get("client")
|
|
133
|
+
key = client[0] if client else "unknown"
|
|
134
|
+
if state.limiter.is_blocked(key):
|
|
135
|
+
return self._page(settings, _ERR_THROTTLED, "/", status=429)
|
|
136
|
+
|
|
137
|
+
form = await request.form()
|
|
138
|
+
supplied = str(form.get("password", ""))
|
|
139
|
+
target = safe_next(str(form.get("next", "/")))
|
|
140
|
+
|
|
141
|
+
if secrets.compare_digest(supplied, settings.password):
|
|
142
|
+
state.limiter.reset(key)
|
|
143
|
+
if session is not None:
|
|
144
|
+
session[c.SESSION_KEY] = fingerprint
|
|
145
|
+
return RedirectResponse(target, status_code=303, headers=_NO_STORE)
|
|
146
|
+
|
|
147
|
+
state.limiter.record_failure(key)
|
|
148
|
+
return self._page(settings, _ERR_WRONG, target, status=401)
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _page(settings, error: str, next_url: str, *, status: int) -> HTMLResponse:
|
|
152
|
+
return HTMLResponse(
|
|
153
|
+
render_unlock_page(message=settings.message, error=error, next_url=next_url),
|
|
154
|
+
status_code=status,
|
|
155
|
+
headers=_NO_STORE,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
async def _is_admin(scope: Scope) -> bool:
|
|
160
|
+
"""True when the caller already holds a session for an admin user.
|
|
161
|
+
|
|
162
|
+
This is the documented lockout escape hatch: an admin who enables the
|
|
163
|
+
gate and mistypes the password keeps access to the settings screen.
|
|
164
|
+
"""
|
|
165
|
+
auth_state = getattr(scope["app"].state, "auth", None)
|
|
166
|
+
provider = getattr(auth_state, "auth_provider", None)
|
|
167
|
+
if provider is None:
|
|
168
|
+
return False
|
|
169
|
+
try:
|
|
170
|
+
user = await provider.resolve_user(Request(scope))
|
|
171
|
+
except Exception:
|
|
172
|
+
logger.exception("Site lock admin bypass failed; treating as anonymous")
|
|
173
|
+
return False
|
|
174
|
+
return user is not None and c.ADMIN_ROLE in getattr(user, "roles", ())
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
__all__ = ["SiteLockMiddleware", "password_fingerprint"]
|
site_lock/module.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Site Lock module — optional site-wide password gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from simple_module_core.module import ModuleBase, ModuleMeta
|
|
8
|
+
|
|
9
|
+
from site_lock import constants as c
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SiteLockModule(ModuleBase):
|
|
16
|
+
meta = ModuleMeta(
|
|
17
|
+
name=c.MODULE_NAME,
|
|
18
|
+
# ``Settings`` so register_module_settings can reach the module
|
|
19
|
+
# registry; ``Auth`` so this module sorts *after* auth and its
|
|
20
|
+
# middleware therefore wraps outermost, executing before
|
|
21
|
+
# AuthMiddleware. Both are load-bearing — see the module README.
|
|
22
|
+
depends_on=[c.MODULE_SETTINGS, c.MODULE_AUTH],
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def register_settings(self, app: FastAPI) -> None:
|
|
26
|
+
import importlib
|
|
27
|
+
|
|
28
|
+
from site_lock.settings import SiteLockSettings
|
|
29
|
+
from site_lock.state import SiteLockState
|
|
30
|
+
|
|
31
|
+
# SM009 is AST-based: resolving via importlib matches the convention
|
|
32
|
+
# used by the other settings-backed modules.
|
|
33
|
+
register_module_settings = importlib.import_module(
|
|
34
|
+
"settings.registration"
|
|
35
|
+
).register_module_settings
|
|
36
|
+
|
|
37
|
+
register_module_settings(
|
|
38
|
+
app,
|
|
39
|
+
c.MODULE_PACKAGE,
|
|
40
|
+
SiteLockSettings,
|
|
41
|
+
lambda s: SiteLockState(settings=s),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def register_middleware(self, app: FastAPI) -> None:
|
|
45
|
+
from site_lock.middleware import SiteLockMiddleware
|
|
46
|
+
|
|
47
|
+
app.add_middleware(SiteLockMiddleware)
|
site_lock/page.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Render the standalone unlock page.
|
|
2
|
+
|
|
3
|
+
Kept out of ``middleware.py`` so the escaping and ``next``-sanitisation rules
|
|
4
|
+
can be unit-tested without an ASGI harness, and so both files stay well under
|
|
5
|
+
the 300-line cap.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import html
|
|
11
|
+
import importlib.resources
|
|
12
|
+
from string import Template
|
|
13
|
+
|
|
14
|
+
_TEMPLATE = Template(
|
|
15
|
+
(importlib.resources.files(__package__) / "templates" / "unlock.html").read_text(
|
|
16
|
+
encoding="utf-8"
|
|
17
|
+
)
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_DEFAULT_MESSAGE = "This site is not public yet. Enter the password to continue."
|
|
21
|
+
_ERROR_BLOCK = '<p class="error" role="alert">{message}</p>'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def safe_next(raw: str | None) -> str:
|
|
25
|
+
"""Return ``raw`` if it is a same-site absolute path, else ``/``.
|
|
26
|
+
|
|
27
|
+
Rejects protocol-relative (``//host``) and backslash-prefixed (``/\\host``)
|
|
28
|
+
targets — browsers resolve both off-site — plus anything carrying CR/LF,
|
|
29
|
+
which could otherwise be smuggled into the redirect header. Without this
|
|
30
|
+
the gate would be an open redirect.
|
|
31
|
+
"""
|
|
32
|
+
if not raw or not raw.startswith("/"):
|
|
33
|
+
return "/"
|
|
34
|
+
if raw.startswith(("//", "/\\")):
|
|
35
|
+
return "/"
|
|
36
|
+
if "\r" in raw or "\n" in raw:
|
|
37
|
+
return "/"
|
|
38
|
+
return raw
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_unlock_page(
|
|
42
|
+
*,
|
|
43
|
+
message: str = "",
|
|
44
|
+
error: str = "",
|
|
45
|
+
next_url: str = "/",
|
|
46
|
+
) -> str:
|
|
47
|
+
"""Render the gate page. Every interpolated value is HTML-escaped."""
|
|
48
|
+
return _TEMPLATE.safe_substitute(
|
|
49
|
+
message=html.escape(message or _DEFAULT_MESSAGE),
|
|
50
|
+
error=_ERROR_BLOCK.format(message=html.escape(error)) if error else "",
|
|
51
|
+
next_url=html.escape(safe_next(next_url), quote=True),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = ["render_unlock_page", "safe_next"]
|
site_lock/py.typed
ADDED
|
File without changes
|
site_lock/rate_limit.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""In-memory per-IP attempt limiter for the unlock endpoint.
|
|
2
|
+
|
|
3
|
+
Deliberately process-local: it mirrors the existing ``users``
|
|
4
|
+
``LoginRateLimiter`` and is adequate for the single-process staging
|
|
5
|
+
deployments this module targets. ``users``' version is not reused because
|
|
6
|
+
importing it would couple ``site_lock`` to the ``users`` module, and the
|
|
7
|
+
site lock must also work under the ``keycloak`` provider.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class _Bucket:
|
|
18
|
+
failures: list[float] = field(default_factory=list)
|
|
19
|
+
blocked_until: float = 0.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AttemptLimiter:
|
|
23
|
+
"""Track failed unlock attempts per client key and impose a cooldown."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
max_failures: int,
|
|
29
|
+
window_seconds: int,
|
|
30
|
+
cooldown_seconds: int,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._max = max_failures
|
|
33
|
+
self._window = window_seconds
|
|
34
|
+
self._cooldown = cooldown_seconds
|
|
35
|
+
self._buckets: dict[str, _Bucket] = {}
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def _now(now: float | None) -> float:
|
|
39
|
+
return time.monotonic() if now is None else now
|
|
40
|
+
|
|
41
|
+
def is_blocked(self, key: str, *, now: float | None = None) -> bool:
|
|
42
|
+
bucket = self._buckets.get(key)
|
|
43
|
+
return bucket is not None and bucket.blocked_until > self._now(now)
|
|
44
|
+
|
|
45
|
+
def record_failure(self, key: str, *, now: float | None = None) -> None:
|
|
46
|
+
moment = self._now(now)
|
|
47
|
+
bucket = self._buckets.setdefault(key, _Bucket())
|
|
48
|
+
bucket.failures = [t for t in bucket.failures if moment - t < self._window]
|
|
49
|
+
bucket.failures.append(moment)
|
|
50
|
+
if len(bucket.failures) >= self._max:
|
|
51
|
+
bucket.blocked_until = moment + self._cooldown
|
|
52
|
+
bucket.failures.clear()
|
|
53
|
+
|
|
54
|
+
def reset(self, key: str) -> None:
|
|
55
|
+
self._buckets.pop(key, None)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = ["AttemptLimiter"]
|
site_lock/settings.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Site Lock module settings — DB-backed via ``register_module_settings``.
|
|
2
|
+
|
|
3
|
+
The field is named ``password`` so the settings admin UI masks it
|
|
4
|
+
automatically (``settings._module_settings.is_secret_field``).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pydantic import model_validator
|
|
10
|
+
from pydantic_core import InitErrorDetails, PydanticCustomError
|
|
11
|
+
from pydantic_core import ValidationError as CoreValidationError
|
|
12
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
13
|
+
|
|
14
|
+
_BLANK_PASSWORD_MSG = "password must be set before enabling the site lock"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SiteLockSettings(BaseSettings):
|
|
18
|
+
"""Site-wide shared-password gate configuration."""
|
|
19
|
+
|
|
20
|
+
model_config = SettingsConfigDict(extra="ignore")
|
|
21
|
+
|
|
22
|
+
enabled: bool = False
|
|
23
|
+
password: str = ""
|
|
24
|
+
message: str = ""
|
|
25
|
+
|
|
26
|
+
@model_validator(mode="after")
|
|
27
|
+
def _password_required_when_enabled(self) -> SiteLockSettings:
|
|
28
|
+
"""Refuse to gate the site behind an empty password.
|
|
29
|
+
|
|
30
|
+
Without this, flipping ``enabled`` on before setting a password would
|
|
31
|
+
lock every visitor out from behind a secret that is the empty string.
|
|
32
|
+
Raising here makes ``apply_changes_and_reload`` reject the change so
|
|
33
|
+
the settings screen shows a validation error instead.
|
|
34
|
+
|
|
35
|
+
The error is deliberately pinned to ``enabled`` rather than raised as a
|
|
36
|
+
bare ``ValueError``. A plain raise from a model validator carries
|
|
37
|
+
``loc=[]``, and the shared settings form only renders errors it can
|
|
38
|
+
attach to a field (``ModuleForm.onSave`` keys them by ``loc[-1]``).
|
|
39
|
+
With an empty ``loc`` the 422 was silently swallowed: the admin saw the
|
|
40
|
+
toggle stay on, no error, and had every reason to believe the site was
|
|
41
|
+
locked when it was still wide open.
|
|
42
|
+
"""
|
|
43
|
+
if self.enabled and not self.password.strip():
|
|
44
|
+
raise CoreValidationError.from_exception_data(
|
|
45
|
+
title=type(self).__name__,
|
|
46
|
+
line_errors=[
|
|
47
|
+
InitErrorDetails(
|
|
48
|
+
type=PydanticCustomError(
|
|
49
|
+
"site_lock_password_required", _BLANK_PASSWORD_MSG
|
|
50
|
+
),
|
|
51
|
+
loc=("enabled",),
|
|
52
|
+
input=self.enabled,
|
|
53
|
+
)
|
|
54
|
+
],
|
|
55
|
+
)
|
|
56
|
+
return self
|
site_lock/state.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Module-owned state attached to ``app.state.site_lock``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from site_lock import constants as c
|
|
8
|
+
from site_lock.rate_limit import AttemptLimiter
|
|
9
|
+
from site_lock.settings import SiteLockSettings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _default_limiter() -> AttemptLimiter:
|
|
13
|
+
return AttemptLimiter(
|
|
14
|
+
max_failures=c.MAX_FAILURES,
|
|
15
|
+
window_seconds=c.WINDOW_SECONDS,
|
|
16
|
+
cooldown_seconds=c.COOLDOWN_SECONDS,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class SiteLockState:
|
|
22
|
+
"""Per-app site-lock state.
|
|
23
|
+
|
|
24
|
+
``settings`` is reassigned in place by ``settings.reload`` on a hot
|
|
25
|
+
reload, so this dataclass must stay mutable. ``limiter`` survives those
|
|
26
|
+
reloads, which is what keeps an in-flight brute-force cooldown from being
|
|
27
|
+
cleared by an unrelated settings save.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
settings: SiteLockSettings
|
|
31
|
+
limiter: AttemptLimiter = field(default_factory=_default_limiter)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = ["SiteLockState"]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="robots" content="noindex, nofollow">
|
|
7
|
+
<title>Protected</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root { color-scheme: light dark; }
|
|
10
|
+
* { box-sizing: border-box; }
|
|
11
|
+
body {
|
|
12
|
+
margin: 0; min-height: 100vh;
|
|
13
|
+
display: flex; align-items: center; justify-content: center;
|
|
14
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
15
|
+
background: #f7f7f8; color: #18181b; padding: 1.5rem;
|
|
16
|
+
}
|
|
17
|
+
.card {
|
|
18
|
+
width: 100%; max-width: 22rem; background: #fff;
|
|
19
|
+
border: 1px solid #e4e4e7; border-radius: 0.75rem;
|
|
20
|
+
padding: 2rem; box-shadow: 0 1px 3px rgba(0,0,0,.06);
|
|
21
|
+
}
|
|
22
|
+
h1 { margin: 0 0 .25rem; font-size: 1.125rem; font-weight: 600; }
|
|
23
|
+
p { margin: 0 0 1.25rem; font-size: .875rem; color: #52525b; line-height: 1.5; }
|
|
24
|
+
label { display: block; font-size: .8125rem; font-weight: 500; margin-bottom: .375rem; }
|
|
25
|
+
input {
|
|
26
|
+
width: 100%; padding: .5rem .75rem; font-size: .875rem;
|
|
27
|
+
border: 1px solid #d4d4d8; border-radius: .5rem; background: #fff; color: inherit;
|
|
28
|
+
}
|
|
29
|
+
input:focus { outline: 2px solid #6366f1; outline-offset: 1px; border-color: transparent; }
|
|
30
|
+
button {
|
|
31
|
+
width: 100%; margin-top: 1rem; padding: .5rem .75rem;
|
|
32
|
+
font-size: .875rem; font-weight: 500; color: #fff; background: #18181b;
|
|
33
|
+
border: 0; border-radius: .5rem; cursor: pointer;
|
|
34
|
+
}
|
|
35
|
+
button:hover { background: #27272a; }
|
|
36
|
+
.error {
|
|
37
|
+
margin: 0 0 1rem; padding: .5rem .75rem; font-size: .8125rem;
|
|
38
|
+
color: #991b1b; background: #fef2f2; border: 1px solid #fecaca; border-radius: .5rem;
|
|
39
|
+
}
|
|
40
|
+
@media (prefers-color-scheme: dark) {
|
|
41
|
+
body { background: #09090b; color: #fafafa; }
|
|
42
|
+
.card { background: #18181b; border-color: #27272a; box-shadow: none; }
|
|
43
|
+
p { color: #a1a1aa; }
|
|
44
|
+
input { background: #09090b; border-color: #3f3f46; }
|
|
45
|
+
button { background: #fafafa; color: #18181b; }
|
|
46
|
+
button:hover { background: #e4e4e7; }
|
|
47
|
+
.error { color: #fca5a5; background: #7f1d1d33; border-color: #7f1d1d; }
|
|
48
|
+
}
|
|
49
|
+
</style>
|
|
50
|
+
</head>
|
|
51
|
+
<body>
|
|
52
|
+
<main class="card">
|
|
53
|
+
<h1>Protected</h1>
|
|
54
|
+
<p>$message</p>
|
|
55
|
+
$error
|
|
56
|
+
<form method="post" action="/__unlock">
|
|
57
|
+
<input type="hidden" name="next" value="$next_url">
|
|
58
|
+
<label for="password">Password</label>
|
|
59
|
+
<input id="password" name="password" type="password" autocomplete="current-password" autofocus required>
|
|
60
|
+
<button type="submit">Continue</button>
|
|
61
|
+
</form>
|
|
62
|
+
</main>
|
|
63
|
+
</body>
|
|
64
|
+
</html>
|