simple-module-site-lock 0.0.30__tar.gz
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/.gitignore +65 -0
- simple_module_site_lock-0.0.30/PKG-INFO +148 -0
- simple_module_site_lock-0.0.30/README.md +122 -0
- simple_module_site_lock-0.0.30/pyproject.toml +47 -0
- simple_module_site_lock-0.0.30/site_lock/__init__.py +0 -0
- simple_module_site_lock-0.0.30/site_lock/constants.py +28 -0
- simple_module_site_lock-0.0.30/site_lock/middleware.py +177 -0
- simple_module_site_lock-0.0.30/site_lock/module.py +47 -0
- simple_module_site_lock-0.0.30/site_lock/page.py +55 -0
- simple_module_site_lock-0.0.30/site_lock/py.typed +0 -0
- simple_module_site_lock-0.0.30/site_lock/rate_limit.py +58 -0
- simple_module_site_lock-0.0.30/site_lock/settings.py +56 -0
- simple_module_site_lock-0.0.30/site_lock/state.py +34 -0
- simple_module_site_lock-0.0.30/site_lock/templates/unlock.html +64 -0
- simple_module_site_lock-0.0.30/tests/test_site_lock_integration.py +75 -0
- simple_module_site_lock-0.0.30/tests/test_site_lock_middleware.py +223 -0
- simple_module_site_lock-0.0.30/tests/test_site_lock_page.py +65 -0
- simple_module_site_lock-0.0.30/tests/test_site_lock_rate_limit.py +60 -0
- simple_module_site_lock-0.0.30/tests/test_site_lock_settings.py +49 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.venv/
|
|
8
|
+
*.egg
|
|
9
|
+
|
|
10
|
+
# UV
|
|
11
|
+
uv.lock
|
|
12
|
+
|
|
13
|
+
# Node
|
|
14
|
+
node_modules/
|
|
15
|
+
|
|
16
|
+
# IDE
|
|
17
|
+
.idea/
|
|
18
|
+
.vscode/
|
|
19
|
+
*.swp
|
|
20
|
+
*.swo
|
|
21
|
+
|
|
22
|
+
# Environment
|
|
23
|
+
.env
|
|
24
|
+
|
|
25
|
+
# Database
|
|
26
|
+
*.db
|
|
27
|
+
*.sqlite3
|
|
28
|
+
|
|
29
|
+
# Module-managed runtime state (e.g. uploaded dataset files,
|
|
30
|
+
# default storage_dir for SM_DATASETS_STORAGE_DIR).
|
|
31
|
+
var/
|
|
32
|
+
|
|
33
|
+
# file_storage filesystem backend default root (override via SM_FILE_STORAGE_FS_ROOT_PATH).
|
|
34
|
+
uploads/
|
|
35
|
+
|
|
36
|
+
# Vite
|
|
37
|
+
host/static/dist/
|
|
38
|
+
|
|
39
|
+
# VitePress (docs)
|
|
40
|
+
docs/.vitepress/cache/
|
|
41
|
+
docs/.vitepress/dist/
|
|
42
|
+
|
|
43
|
+
# Auto-generated frontend module manifest (regenerated by the host at boot
|
|
44
|
+
# or via `make gen-pages`).
|
|
45
|
+
host/client_app/modules.manifest.json
|
|
46
|
+
host/client_app/modules.generated.ts
|
|
47
|
+
host/client_app/modules.generated.css
|
|
48
|
+
host/client_app/modules.assets.json
|
|
49
|
+
|
|
50
|
+
# Worktrees
|
|
51
|
+
.worktrees/
|
|
52
|
+
|
|
53
|
+
# Performance profiles
|
|
54
|
+
.memray/
|
|
55
|
+
.benchmarks/
|
|
56
|
+
|
|
57
|
+
# OS
|
|
58
|
+
.DS_Store
|
|
59
|
+
Thumbs.db
|
|
60
|
+
|
|
61
|
+
.playwright-cli/*
|
|
62
|
+
.playwright-mcp/*
|
|
63
|
+
host/client_app/.playwright-cli/*
|
|
64
|
+
.superpowers/
|
|
65
|
+
.qa/
|
|
@@ -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,122 @@
|
|
|
1
|
+
# simple_module_site_lock
|
|
2
|
+
|
|
3
|
+
Optional site-wide password gate for `simple_module` apps — a staging /
|
|
4
|
+
pre-launch door. When enabled, every visitor must enter one shared password
|
|
5
|
+
before they can see anything: not the landing page, not the API, not even
|
|
6
|
+
that a login form exists.
|
|
7
|
+
|
|
8
|
+
**Off by default.** Installing this module changes nothing until an operator
|
|
9
|
+
turns it on.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Add the package to your host and re-sync — discovery picks it up from the
|
|
14
|
+
`simple_module` entry point, no host code changes:
|
|
15
|
+
|
|
16
|
+
```toml
|
|
17
|
+
# host/pyproject.toml
|
|
18
|
+
dependencies = ["simple_module_site_lock"]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv sync --all-packages
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or scaffold a new app with it selected: `smpy new --modules site_lock`.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
1. Sign in as an admin and open **Settings → Modules → SiteLock**.
|
|
30
|
+
2. Set `password` (and optionally `message`), tick `enabled`, and **Save**.
|
|
31
|
+
Saving with a blank password is refused — the error appears on the
|
|
32
|
+
`enabled` field and nothing is persisted.
|
|
33
|
+
3. Anyone without the password now gets the gate page at `/__unlock` on every
|
|
34
|
+
URL. They enter the password once and are returned to where they were
|
|
35
|
+
heading.
|
|
36
|
+
4. To lift the gate, untick `enabled` and save. To rotate the password, set a
|
|
37
|
+
new one — every already-unlocked visitor is logged back out of the gate.
|
|
38
|
+
|
|
39
|
+
Keep your own session alive while you do this: an admin with a live session
|
|
40
|
+
skips the gate, which is what lets you undo a mistake (see
|
|
41
|
+
[Admin bypass](#admin-bypass-and-the-lockout-you-can-still-cause) below).
|
|
42
|
+
|
|
43
|
+
## How it works
|
|
44
|
+
|
|
45
|
+
The module installs a single middleware that runs *before* `AuthMiddleware`.
|
|
46
|
+
That ordering is the whole point — an anonymous visitor gets the gate rather
|
|
47
|
+
than a redirect to the login page, so a locked site never reveals that it has
|
|
48
|
+
one.
|
|
49
|
+
|
|
50
|
+
It achieves that ordering by declaring `depends_on=["Settings", "Auth"]`:
|
|
51
|
+
modules are installed in topological order and Starlette's `add_middleware`
|
|
52
|
+
is LIFO, so sorting after `Auth` makes this middleware wrap outermost.
|
|
53
|
+
|
|
54
|
+
Unlock state lives in the signed session cookie. The stored marker is a
|
|
55
|
+
fingerprint of the current password, so **rotating the password immediately
|
|
56
|
+
invalidates every unlocked session**.
|
|
57
|
+
|
|
58
|
+
## Enabling it
|
|
59
|
+
|
|
60
|
+
Settings → Site Lock:
|
|
61
|
+
|
|
62
|
+
| Field | Default | Meaning |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `enabled` | `false` | Master switch |
|
|
65
|
+
| `password` | `""` | The shared password. Masked in the admin UI |
|
|
66
|
+
| `message` | `""` | Optional line shown on the gate page |
|
|
67
|
+
|
|
68
|
+
Changes apply immediately — no restart. Enabling with a blank password is
|
|
69
|
+
rejected by a validator, so you cannot accidentally gate the site behind the
|
|
70
|
+
empty string.
|
|
71
|
+
|
|
72
|
+
## What stays reachable when locked
|
|
73
|
+
|
|
74
|
+
- `/health` — always. Gating it would fail Kubernetes liveness/readiness
|
|
75
|
+
probes and get the pod killed.
|
|
76
|
+
- `/__unlock` — the gate page itself.
|
|
77
|
+
|
|
78
|
+
Everything else is gated. Requests under `/api/`, and any request carrying an
|
|
79
|
+
`Authorization` header, get `403 {"detail": "Site is locked"}` rather than a
|
|
80
|
+
redirect — a `401` would invite an auth flow that cannot succeed while the
|
|
81
|
+
site is locked. Browser requests get a `302` to the gate.
|
|
82
|
+
|
|
83
|
+
That includes the login API itself, so while the gate is on there is no
|
|
84
|
+
programmatic way in: CI smoke tests, uptime monitors, mobile clients and
|
|
85
|
+
webhook senders all get `403` no matter what credentials they hold. Only a
|
|
86
|
+
browser that has passed the gate (or an admin holding a live session) can
|
|
87
|
+
reach anything. Pause those integrations before enabling the gate, or point
|
|
88
|
+
uptime checks at `/health`, which stays open.
|
|
89
|
+
|
|
90
|
+
## Admin bypass, and the lockout you can still cause
|
|
91
|
+
|
|
92
|
+
A user who already holds a session with the `admin` role skips the gate.
|
|
93
|
+
This is the intended escape hatch: if you enable the gate and mistype the
|
|
94
|
+
password, you are still holding the session that lets you go straight back to
|
|
95
|
+
Settings and fix it.
|
|
96
|
+
|
|
97
|
+
**It only rescues a live session.** If no admin is currently signed in and the
|
|
98
|
+
password has been forgotten, there is no in-app recovery. Clear the override
|
|
99
|
+
directly in the database:
|
|
100
|
+
|
|
101
|
+
```sql
|
|
102
|
+
DELETE FROM settings_setting
|
|
103
|
+
WHERE scope = 'system' AND key = 'site_lock.enabled';
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Then restart the app (or save any setting) so the module re-hydrates.
|
|
107
|
+
|
|
108
|
+
## Brute-force protection
|
|
109
|
+
|
|
110
|
+
The unlock endpoint tracks failures per client IP in memory: 10 failures
|
|
111
|
+
within 5 minutes trigger a 15-minute cooldown returning `429`. These
|
|
112
|
+
thresholds are module constants, not settings — they are the only defence on
|
|
113
|
+
a single shared secret, so they are not an operator-tunable surface.
|
|
114
|
+
|
|
115
|
+
The limiter is process-local, which is adequate for the single-process
|
|
116
|
+
staging deployments this module targets.
|
|
117
|
+
|
|
118
|
+
## What this is not
|
|
119
|
+
|
|
120
|
+
This is not user authentication or authorisation — that is what the `auth`,
|
|
121
|
+
`users`, and `permissions` modules are for. The site lock is one shared
|
|
122
|
+
secret in front of everything, with no notion of identity.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "simple_module_site_lock"
|
|
3
|
+
version = "0.0.30"
|
|
4
|
+
description = "Optional site-wide password gate (staging / pre-launch) for simple_module apps"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
|
|
9
|
+
keywords = ["simple-module", "password", "staging", "gate"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Framework :: FastAPI",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"simple_module_core==0.0.30",
|
|
23
|
+
"simple_module_hosting==0.0.30",
|
|
24
|
+
"simple_module_settings==0.0.30",
|
|
25
|
+
"simple_module_auth==0.0.30",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.entry-points.simple_module]
|
|
29
|
+
site_lock = "site_lock.module:SiteLockModule"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/antosubash/simple_module_python"
|
|
33
|
+
Repository = "https://github.com/antosubash/simple_module_python"
|
|
34
|
+
Issues = "https://github.com/antosubash/simple_module_python/issues"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["hatchling"]
|
|
38
|
+
build-backend = "hatchling.build"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["site_lock"]
|
|
42
|
+
|
|
43
|
+
[tool.uv.sources]
|
|
44
|
+
simple_module_core = { workspace = true }
|
|
45
|
+
simple_module_hosting = { workspace = true }
|
|
46
|
+
simple_module_settings = { workspace = true }
|
|
47
|
+
simple_module_auth = { workspace = true }
|
|
File without changes
|
|
@@ -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"]
|
|
@@ -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)
|
|
@@ -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"]
|