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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [simple_module]
2
+ site_lock = site_lock.module:SiteLockModule
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
@@ -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
@@ -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>