xlock-py 0.1.0__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.
- xlock_py-0.1.0/.gitignore +36 -0
- xlock_py-0.1.0/LICENSE +21 -0
- xlock_py-0.1.0/PKG-INFO +123 -0
- xlock_py-0.1.0/README.md +83 -0
- xlock_py-0.1.0/pyproject.toml +54 -0
- xlock_py-0.1.0/xlock/__init__.py +38 -0
- xlock_py-0.1.0/xlock/middleware.py +282 -0
- xlock_py-0.1.0/xlock/py.typed +0 -0
- xlock_py-0.1.0/xlock/verify.py +90 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
*.pyc
|
|
3
|
+
__pycache__/
|
|
4
|
+
.env
|
|
5
|
+
.env.local
|
|
6
|
+
*.db
|
|
7
|
+
dist/
|
|
8
|
+
bun.lock
|
|
9
|
+
xlock-server
|
|
10
|
+
server/xlock-server
|
|
11
|
+
server/server
|
|
12
|
+
|
|
13
|
+
# OS
|
|
14
|
+
.DS_Store
|
|
15
|
+
Thumbs.db
|
|
16
|
+
|
|
17
|
+
# IDE
|
|
18
|
+
.idea/
|
|
19
|
+
.vscode/
|
|
20
|
+
*.swp
|
|
21
|
+
*.swo
|
|
22
|
+
*~
|
|
23
|
+
|
|
24
|
+
# WASM build tooling (cloned repos, venvs)
|
|
25
|
+
compiler/wasm/wasmixer/
|
|
26
|
+
compiler/wasm/.venv/
|
|
27
|
+
|
|
28
|
+
# WASM intermediate build artifacts (keep .wasm outputs)
|
|
29
|
+
compiler/wasm/build/*.wat
|
|
30
|
+
compiler/wasm/build/*.d.ts
|
|
31
|
+
compiler/wasm/build/*.js
|
|
32
|
+
.claude/worktrees/
|
|
33
|
+
capture.html
|
|
34
|
+
mouse_capture.js
|
|
35
|
+
seo-outreach-training.html
|
|
36
|
+
test_cloakbrowser.ts
|
xlock_py-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 x-lock
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
xlock_py-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xlock-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: x-lock bot protection middleware for Python — invisible alternative to reCAPTCHA
|
|
5
|
+
Project-URL: Homepage, https://x-lock.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/x-lock-dev/xlock-python
|
|
7
|
+
Project-URL: Issues, https://github.com/x-lock-dev/xlock-python/issues
|
|
8
|
+
Author-email: x-lock <dev@x-lock.dev>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: bot-protection,captcha,django,fastapi,flask,middleware,proof-of-work,recaptcha-alternative,xlock
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Framework :: Flask
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
26
|
+
Classifier: Topic :: Security
|
|
27
|
+
Requires-Python: >=3.8
|
|
28
|
+
Provides-Extra: all
|
|
29
|
+
Requires-Dist: flask>=2.0.0; extra == 'all'
|
|
30
|
+
Requires-Dist: httpx>=0.24.0; extra == 'all'
|
|
31
|
+
Requires-Dist: starlette>=0.20.0; extra == 'all'
|
|
32
|
+
Provides-Extra: django
|
|
33
|
+
Provides-Extra: fastapi
|
|
34
|
+
Requires-Dist: httpx>=0.24.0; extra == 'fastapi'
|
|
35
|
+
Requires-Dist: starlette>=0.20.0; extra == 'fastapi'
|
|
36
|
+
Provides-Extra: flask
|
|
37
|
+
Requires-Dist: flask>=2.0.0; extra == 'flask'
|
|
38
|
+
Requires-Dist: httpx>=0.24.0; extra == 'flask'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# xlock-py
|
|
42
|
+
|
|
43
|
+
Invisible bot protection middleware for Python. Drop-in replacement for reCAPTCHA, Turnstile, and hCaptcha — no visual challenges, no user friction.
|
|
44
|
+
|
|
45
|
+
Supports **FastAPI**, **Django**, and **Flask**.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Core (Django — no extra deps)
|
|
51
|
+
pip install xlock-py
|
|
52
|
+
|
|
53
|
+
# FastAPI / Starlette
|
|
54
|
+
pip install xlock-py[fastapi]
|
|
55
|
+
|
|
56
|
+
# Flask
|
|
57
|
+
pip install xlock-py[flask]
|
|
58
|
+
|
|
59
|
+
# Everything
|
|
60
|
+
pip install xlock-py[all]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Quick Start
|
|
64
|
+
|
|
65
|
+
### FastAPI / Starlette
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from fastapi import FastAPI
|
|
69
|
+
from xlock import XLockMiddleware
|
|
70
|
+
|
|
71
|
+
app = FastAPI()
|
|
72
|
+
app.add_middleware(
|
|
73
|
+
XLockMiddleware,
|
|
74
|
+
site_key="sk_...",
|
|
75
|
+
protected_paths=["/api/auth", "/api/checkout"],
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Django
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
# settings.py
|
|
83
|
+
MIDDLEWARE = [
|
|
84
|
+
"xlock.XLockDjangoMiddleware",
|
|
85
|
+
# ... other middleware
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
XLOCK_SITE_KEY = "sk_..."
|
|
89
|
+
XLOCK_PROTECTED_PATHS = ["/api/auth/", "/api/checkout/"]
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Flask
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from flask import Flask
|
|
96
|
+
from xlock import XLockFlask
|
|
97
|
+
|
|
98
|
+
app = Flask(__name__)
|
|
99
|
+
xlock = XLockFlask(app, site_key="sk_...", protected_paths=["/api/auth"])
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Direct Verification
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from xlock import verify
|
|
106
|
+
|
|
107
|
+
result = verify(token="v3.abc123...", site_key="sk_...", path="/api/login")
|
|
108
|
+
if result.blocked:
|
|
109
|
+
print(f"Blocked: {result.reason}")
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Configuration
|
|
113
|
+
|
|
114
|
+
| Option | Env Var | Default | Description |
|
|
115
|
+
|--------|---------|---------|-------------|
|
|
116
|
+
| `site_key` | `XLOCK_SITE_KEY` | — | Your x-lock site key |
|
|
117
|
+
| `api_url` | `XLOCK_API_URL` | `https://api.x-lock.dev` | API endpoint |
|
|
118
|
+
| `fail_open` | `XLOCK_FAIL_OPEN` | `True` | Allow requests on API errors |
|
|
119
|
+
| `protected_paths` | `XLOCK_PROTECTED_PATHS` | `[]` | Path prefixes to protect |
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT
|
xlock_py-0.1.0/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# xlock-py
|
|
2
|
+
|
|
3
|
+
Invisible bot protection middleware for Python. Drop-in replacement for reCAPTCHA, Turnstile, and hCaptcha — no visual challenges, no user friction.
|
|
4
|
+
|
|
5
|
+
Supports **FastAPI**, **Django**, and **Flask**.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Core (Django — no extra deps)
|
|
11
|
+
pip install xlock-py
|
|
12
|
+
|
|
13
|
+
# FastAPI / Starlette
|
|
14
|
+
pip install xlock-py[fastapi]
|
|
15
|
+
|
|
16
|
+
# Flask
|
|
17
|
+
pip install xlock-py[flask]
|
|
18
|
+
|
|
19
|
+
# Everything
|
|
20
|
+
pip install xlock-py[all]
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
### FastAPI / Starlette
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from fastapi import FastAPI
|
|
29
|
+
from xlock import XLockMiddleware
|
|
30
|
+
|
|
31
|
+
app = FastAPI()
|
|
32
|
+
app.add_middleware(
|
|
33
|
+
XLockMiddleware,
|
|
34
|
+
site_key="sk_...",
|
|
35
|
+
protected_paths=["/api/auth", "/api/checkout"],
|
|
36
|
+
)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Django
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
# settings.py
|
|
43
|
+
MIDDLEWARE = [
|
|
44
|
+
"xlock.XLockDjangoMiddleware",
|
|
45
|
+
# ... other middleware
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
XLOCK_SITE_KEY = "sk_..."
|
|
49
|
+
XLOCK_PROTECTED_PATHS = ["/api/auth/", "/api/checkout/"]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Flask
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from flask import Flask
|
|
56
|
+
from xlock import XLockFlask
|
|
57
|
+
|
|
58
|
+
app = Flask(__name__)
|
|
59
|
+
xlock = XLockFlask(app, site_key="sk_...", protected_paths=["/api/auth"])
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Direct Verification
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from xlock import verify
|
|
66
|
+
|
|
67
|
+
result = verify(token="v3.abc123...", site_key="sk_...", path="/api/login")
|
|
68
|
+
if result.blocked:
|
|
69
|
+
print(f"Blocked: {result.reason}")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
| Option | Env Var | Default | Description |
|
|
75
|
+
|--------|---------|---------|-------------|
|
|
76
|
+
| `site_key` | `XLOCK_SITE_KEY` | — | Your x-lock site key |
|
|
77
|
+
| `api_url` | `XLOCK_API_URL` | `https://api.x-lock.dev` | API endpoint |
|
|
78
|
+
| `fail_open` | `XLOCK_FAIL_OPEN` | `True` | Allow requests on API errors |
|
|
79
|
+
| `protected_paths` | `XLOCK_PROTECTED_PATHS` | `[]` | Path prefixes to protect |
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xlock-py"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "x-lock bot protection middleware for Python — invisible alternative to reCAPTCHA"
|
|
9
|
+
requires-python = ">=3.8"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
authors = [{ name = "x-lock", email = "dev@x-lock.dev" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"xlock",
|
|
15
|
+
"bot-protection",
|
|
16
|
+
"captcha",
|
|
17
|
+
"middleware",
|
|
18
|
+
"fastapi",
|
|
19
|
+
"django",
|
|
20
|
+
"flask",
|
|
21
|
+
"recaptcha-alternative",
|
|
22
|
+
"proof-of-work",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Framework :: Django",
|
|
27
|
+
"Framework :: FastAPI",
|
|
28
|
+
"Framework :: Flask",
|
|
29
|
+
"Intended Audience :: Developers",
|
|
30
|
+
"License :: OSI Approved :: MIT License",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3.8",
|
|
33
|
+
"Programming Language :: Python :: 3.9",
|
|
34
|
+
"Programming Language :: Python :: 3.10",
|
|
35
|
+
"Programming Language :: Python :: 3.11",
|
|
36
|
+
"Programming Language :: Python :: 3.12",
|
|
37
|
+
"Programming Language :: Python :: 3.13",
|
|
38
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
39
|
+
"Topic :: Security",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.optional-dependencies]
|
|
43
|
+
fastapi = ["httpx>=0.24.0", "starlette>=0.20.0"]
|
|
44
|
+
flask = ["httpx>=0.24.0", "flask>=2.0.0"]
|
|
45
|
+
django = []
|
|
46
|
+
all = ["httpx>=0.24.0", "starlette>=0.20.0", "flask>=2.0.0"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["xlock"]
|
|
50
|
+
|
|
51
|
+
[project.urls]
|
|
52
|
+
Homepage = "https://x-lock.dev"
|
|
53
|
+
Repository = "https://github.com/x-lock-dev/xlock-python"
|
|
54
|
+
Issues = "https://github.com/x-lock-dev/xlock-python/issues"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
xlock — invisible bot protection middleware for Python
|
|
3
|
+
|
|
4
|
+
Supports FastAPI/Starlette, Django, and Flask.
|
|
5
|
+
|
|
6
|
+
FastAPI:
|
|
7
|
+
from xlock import XLockMiddleware
|
|
8
|
+
app.add_middleware(XLockMiddleware, site_key="sk_...", protected_paths=["/api/auth"])
|
|
9
|
+
|
|
10
|
+
Django (settings.py):
|
|
11
|
+
MIDDLEWARE = ["xlock.XLockDjangoMiddleware", ...]
|
|
12
|
+
XLOCK_SITE_KEY = "sk_..."
|
|
13
|
+
XLOCK_PROTECTED_PATHS = ["/api/auth/"]
|
|
14
|
+
|
|
15
|
+
Flask:
|
|
16
|
+
from xlock import XLockFlask
|
|
17
|
+
xlock = XLockFlask(app, site_key="sk_...", protected_paths=["/api/auth"])
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from xlock.middleware import XLockDjangoMiddleware, XLockFlask
|
|
21
|
+
from xlock.verify import verify, verify_async
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"XLockMiddleware",
|
|
25
|
+
"XLockDjangoMiddleware",
|
|
26
|
+
"XLockFlask",
|
|
27
|
+
"verify",
|
|
28
|
+
"verify_async",
|
|
29
|
+
]
|
|
30
|
+
__version__ = "0.1.0"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def __getattr__(name: str):
|
|
34
|
+
if name == "XLockMiddleware":
|
|
35
|
+
from xlock.middleware import XLockMiddleware
|
|
36
|
+
|
|
37
|
+
return XLockMiddleware
|
|
38
|
+
raise AttributeError(f"module 'xlock' has no attribute {name!r}")
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""
|
|
2
|
+
x-lock middleware implementations for FastAPI/Starlette, Django, and Flask.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import urllib.request
|
|
9
|
+
import urllib.error
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("x-lock")
|
|
12
|
+
|
|
13
|
+
DEFAULT_API_URL = "https://api.x-lock.dev"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ─── FastAPI / Starlette ───
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
20
|
+
from starlette.requests import Request
|
|
21
|
+
from starlette.responses import JSONResponse
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
class XLockMiddleware(BaseHTTPMiddleware):
|
|
25
|
+
"""
|
|
26
|
+
x-lock middleware for FastAPI / Starlette.
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
from xlock import XLockMiddleware
|
|
30
|
+
app.add_middleware(
|
|
31
|
+
XLockMiddleware,
|
|
32
|
+
site_key="sk_...",
|
|
33
|
+
protected_paths=["/api/auth", "/api/checkout"],
|
|
34
|
+
)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, app, **kwargs):
|
|
38
|
+
super().__init__(app)
|
|
39
|
+
self.api_url = kwargs.get("api_url", DEFAULT_API_URL)
|
|
40
|
+
self.site_key = kwargs.get("site_key") or os.environ.get("XLOCK_SITE_KEY")
|
|
41
|
+
self.fail_open = kwargs.get("fail_open", True)
|
|
42
|
+
self.protected_paths: list = kwargs.get("protected_paths", [])
|
|
43
|
+
|
|
44
|
+
if not self.site_key:
|
|
45
|
+
logger.warning("No site key configured — skipping enforcement")
|
|
46
|
+
|
|
47
|
+
def _matches(self, path: str) -> bool:
|
|
48
|
+
return any(path.startswith(p) for p in self.protected_paths)
|
|
49
|
+
|
|
50
|
+
async def dispatch(self, request: Request, call_next):
|
|
51
|
+
if not self.site_key:
|
|
52
|
+
return await call_next(request)
|
|
53
|
+
if request.method != "POST":
|
|
54
|
+
return await call_next(request)
|
|
55
|
+
if self.protected_paths and not self._matches(request.url.path):
|
|
56
|
+
return await call_next(request)
|
|
57
|
+
|
|
58
|
+
token = request.headers.get("x-lock")
|
|
59
|
+
|
|
60
|
+
if not token:
|
|
61
|
+
return JSONResponse(
|
|
62
|
+
{"error": "Blocked by x-lock: missing token"}, status_code=403
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
if token.startswith("v3."):
|
|
67
|
+
session_id = token.split(".")[1]
|
|
68
|
+
enforce_url = f"{self.api_url}/v3/session/enforce"
|
|
69
|
+
enforce_body = {"sessionId": session_id, "siteKey": self.site_key, "path": str(request.url.path)}
|
|
70
|
+
else:
|
|
71
|
+
enforce_url = f"{self.api_url}/v1/enforce"
|
|
72
|
+
enforce_body = {"token": token, "siteKey": self.site_key, "path": str(request.url.path)}
|
|
73
|
+
|
|
74
|
+
async with httpx.AsyncClient(timeout=5) as client:
|
|
75
|
+
res = await client.post(
|
|
76
|
+
enforce_url,
|
|
77
|
+
json=enforce_body,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if res.status_code == 403:
|
|
81
|
+
data = res.json()
|
|
82
|
+
return JSONResponse(
|
|
83
|
+
{"error": "Blocked by x-lock", "reason": data.get("reason")},
|
|
84
|
+
status_code=403,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if not res.is_success and not self.fail_open:
|
|
88
|
+
return JSONResponse(
|
|
89
|
+
{"error": "x-lock verification failed"}, status_code=403
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
except Exception as e:
|
|
93
|
+
logger.error(f"Enforcement error: {e}")
|
|
94
|
+
if not self.fail_open:
|
|
95
|
+
return JSONResponse(
|
|
96
|
+
{"error": "x-lock verification failed"}, status_code=403
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return await call_next(request)
|
|
100
|
+
|
|
101
|
+
except ImportError:
|
|
102
|
+
# Starlette/httpx not installed — provide a stub
|
|
103
|
+
class XLockMiddleware: # type: ignore[no-redef]
|
|
104
|
+
"""Stub — install starlette and httpx for FastAPI support."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, *args, **kwargs):
|
|
107
|
+
raise ImportError(
|
|
108
|
+
"Install starlette and httpx for FastAPI/Starlette support: "
|
|
109
|
+
"pip install xlock[fastapi]"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ─── Django ───
|
|
114
|
+
|
|
115
|
+
class XLockDjangoMiddleware:
|
|
116
|
+
"""
|
|
117
|
+
x-lock middleware for Django.
|
|
118
|
+
|
|
119
|
+
Usage (settings.py):
|
|
120
|
+
MIDDLEWARE = ["xlock.XLockDjangoMiddleware", ...]
|
|
121
|
+
XLOCK_SITE_KEY = "sk_..."
|
|
122
|
+
XLOCK_PROTECTED_PATHS = ["/api/auth/", "/api/checkout/"]
|
|
123
|
+
XLOCK_API_URL = "https://api.x-lock.dev" # optional
|
|
124
|
+
XLOCK_FAIL_OPEN = True # optional
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, get_response):
|
|
128
|
+
self.get_response = get_response
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
from django.conf import settings as django_settings
|
|
132
|
+
|
|
133
|
+
self.api_url = getattr(django_settings, "XLOCK_API_URL", DEFAULT_API_URL)
|
|
134
|
+
self.site_key = getattr(django_settings, "XLOCK_SITE_KEY", None) or os.environ.get("XLOCK_SITE_KEY")
|
|
135
|
+
self.fail_open = getattr(django_settings, "XLOCK_FAIL_OPEN", True)
|
|
136
|
+
self.protected_paths = getattr(django_settings, "XLOCK_PROTECTED_PATHS", [])
|
|
137
|
+
except Exception:
|
|
138
|
+
self.api_url = os.environ.get("XLOCK_API_URL", DEFAULT_API_URL)
|
|
139
|
+
self.site_key = os.environ.get("XLOCK_SITE_KEY")
|
|
140
|
+
self.fail_open = True
|
|
141
|
+
self.protected_paths = []
|
|
142
|
+
|
|
143
|
+
if not self.site_key:
|
|
144
|
+
logger.warning("No site key configured — skipping enforcement")
|
|
145
|
+
|
|
146
|
+
def __call__(self, request):
|
|
147
|
+
if self.site_key and request.method == "POST":
|
|
148
|
+
if not self.protected_paths or self._matches(request.path):
|
|
149
|
+
result = self._enforce(request)
|
|
150
|
+
if result is not None:
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
return self.get_response(request)
|
|
154
|
+
|
|
155
|
+
def _matches(self, path: str) -> bool:
|
|
156
|
+
return any(path.startswith(p) for p in self.protected_paths)
|
|
157
|
+
|
|
158
|
+
def _enforce(self, request):
|
|
159
|
+
try:
|
|
160
|
+
from django.http import JsonResponse
|
|
161
|
+
except ImportError:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
token = request.META.get("HTTP_X_LOCK")
|
|
165
|
+
|
|
166
|
+
if not token:
|
|
167
|
+
return JsonResponse({"error": "Blocked by x-lock: missing token"}, status=403)
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
if token.startswith("v3."):
|
|
171
|
+
session_id = token.split(".")[1]
|
|
172
|
+
enforce_url = f"{self.api_url}/v3/session/enforce"
|
|
173
|
+
payload = json.dumps({"sessionId": session_id, "siteKey": self.site_key, "path": request.path}).encode()
|
|
174
|
+
else:
|
|
175
|
+
enforce_url = f"{self.api_url}/v1/enforce"
|
|
176
|
+
payload = json.dumps({"token": token, "siteKey": self.site_key, "path": request.path}).encode()
|
|
177
|
+
|
|
178
|
+
req = urllib.request.Request(
|
|
179
|
+
enforce_url,
|
|
180
|
+
data=payload,
|
|
181
|
+
headers={"Content-Type": "application/json"},
|
|
182
|
+
method="POST",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
with urllib.request.urlopen(req, timeout=5) as res:
|
|
186
|
+
pass # 200 = allowed
|
|
187
|
+
|
|
188
|
+
except urllib.error.HTTPError as e:
|
|
189
|
+
if e.code == 403:
|
|
190
|
+
data = json.loads(e.read().decode())
|
|
191
|
+
return JsonResponse(
|
|
192
|
+
{"error": "Blocked by x-lock", "reason": data.get("reason")},
|
|
193
|
+
status=403,
|
|
194
|
+
)
|
|
195
|
+
if not self.fail_open:
|
|
196
|
+
return JsonResponse({"error": "x-lock verification failed"}, status=403)
|
|
197
|
+
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.error(f"Enforcement error: {e}")
|
|
200
|
+
if not self.fail_open:
|
|
201
|
+
return JsonResponse({"error": "x-lock verification failed"}, status=403)
|
|
202
|
+
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ─── Flask ───
|
|
207
|
+
|
|
208
|
+
class XLockFlask:
|
|
209
|
+
"""
|
|
210
|
+
x-lock middleware for Flask.
|
|
211
|
+
|
|
212
|
+
Usage:
|
|
213
|
+
from xlock import XLockFlask
|
|
214
|
+
app = Flask(__name__)
|
|
215
|
+
xlock = XLockFlask(app, site_key="sk_...", protected_paths=["/api/auth"])
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def __init__(self, app=None, **kwargs):
|
|
219
|
+
self.api_url = kwargs.get("api_url", DEFAULT_API_URL)
|
|
220
|
+
self.site_key = kwargs.get("site_key") or os.environ.get("XLOCK_SITE_KEY")
|
|
221
|
+
self.fail_open = kwargs.get("fail_open", True)
|
|
222
|
+
self.protected_paths: list = kwargs.get("protected_paths", [])
|
|
223
|
+
|
|
224
|
+
if app:
|
|
225
|
+
self.init_app(app)
|
|
226
|
+
|
|
227
|
+
def init_app(self, app):
|
|
228
|
+
if not self.site_key:
|
|
229
|
+
app.logger.warning("[x-lock] No site key configured — skipping enforcement")
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
app.before_request(self._enforce)
|
|
233
|
+
|
|
234
|
+
def _matches(self, path: str) -> bool:
|
|
235
|
+
return any(path.startswith(p) for p in self.protected_paths)
|
|
236
|
+
|
|
237
|
+
def _enforce(self):
|
|
238
|
+
try:
|
|
239
|
+
from flask import request, jsonify
|
|
240
|
+
except ImportError:
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
if request.method != "POST":
|
|
244
|
+
return None
|
|
245
|
+
if self.protected_paths and not self._matches(request.path):
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
token = request.headers.get("x-lock")
|
|
249
|
+
|
|
250
|
+
if not token:
|
|
251
|
+
return jsonify(error="Blocked by x-lock: missing token"), 403
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
import httpx
|
|
255
|
+
|
|
256
|
+
if token.startswith("v3."):
|
|
257
|
+
session_id = token.split(".")[1]
|
|
258
|
+
enforce_url = f"{self.api_url}/v3/session/enforce"
|
|
259
|
+
enforce_body = {"sessionId": session_id, "siteKey": self.site_key, "path": request.path}
|
|
260
|
+
else:
|
|
261
|
+
enforce_url = f"{self.api_url}/v1/enforce"
|
|
262
|
+
enforce_body = {"token": token, "siteKey": self.site_key, "path": request.path}
|
|
263
|
+
|
|
264
|
+
with httpx.Client(timeout=5) as client:
|
|
265
|
+
res = client.post(
|
|
266
|
+
enforce_url,
|
|
267
|
+
json=enforce_body,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
if res.status_code == 403:
|
|
271
|
+
data = res.json()
|
|
272
|
+
return jsonify(error="Blocked by x-lock", reason=data.get("reason")), 403
|
|
273
|
+
|
|
274
|
+
if not res.ok and not self.fail_open:
|
|
275
|
+
return jsonify(error="x-lock verification failed"), 403
|
|
276
|
+
|
|
277
|
+
except Exception as e:
|
|
278
|
+
logger.error(f"Enforcement error: {e}")
|
|
279
|
+
if not self.fail_open:
|
|
280
|
+
return jsonify(error="x-lock verification failed"), 403
|
|
281
|
+
|
|
282
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core x-lock verification — usable standalone without any framework.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.request
|
|
7
|
+
import urllib.error
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
DEFAULT_API_URL = "https://api.x-lock.dev"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class VerifyResult:
|
|
14
|
+
__slots__ = ("blocked", "reason", "error")
|
|
15
|
+
|
|
16
|
+
def __init__(self, blocked: bool = False, reason: Optional[str] = None, error: bool = False):
|
|
17
|
+
self.blocked = blocked
|
|
18
|
+
self.reason = reason
|
|
19
|
+
self.error = error
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def verify(
|
|
23
|
+
token: str,
|
|
24
|
+
site_key: str,
|
|
25
|
+
path: Optional[str] = None,
|
|
26
|
+
api_url: str = DEFAULT_API_URL,
|
|
27
|
+
) -> VerifyResult:
|
|
28
|
+
"""Synchronously verify an x-lock token. Works with stdlib only."""
|
|
29
|
+
if token.startswith("v3."):
|
|
30
|
+
session_id = token.split(".")[1]
|
|
31
|
+
enforce_url = f"{api_url}/v3/session/enforce"
|
|
32
|
+
body = {"sessionId": session_id, "siteKey": site_key}
|
|
33
|
+
else:
|
|
34
|
+
enforce_url = f"{api_url}/v1/enforce"
|
|
35
|
+
body = {"token": token, "siteKey": site_key}
|
|
36
|
+
|
|
37
|
+
if path:
|
|
38
|
+
body["path"] = path
|
|
39
|
+
|
|
40
|
+
payload = json.dumps(body).encode()
|
|
41
|
+
req = urllib.request.Request(
|
|
42
|
+
enforce_url,
|
|
43
|
+
data=payload,
|
|
44
|
+
headers={"Content-Type": "application/json"},
|
|
45
|
+
method="POST",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
with urllib.request.urlopen(req, timeout=5):
|
|
50
|
+
return VerifyResult(blocked=False)
|
|
51
|
+
except urllib.error.HTTPError as e:
|
|
52
|
+
if e.code == 403:
|
|
53
|
+
data = json.loads(e.read().decode())
|
|
54
|
+
return VerifyResult(blocked=True, reason=data.get("reason"))
|
|
55
|
+
return VerifyResult(blocked=False, error=True)
|
|
56
|
+
except Exception:
|
|
57
|
+
return VerifyResult(blocked=False, error=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def verify_async(
|
|
61
|
+
token: str,
|
|
62
|
+
site_key: str,
|
|
63
|
+
path: Optional[str] = None,
|
|
64
|
+
api_url: str = DEFAULT_API_URL,
|
|
65
|
+
) -> VerifyResult:
|
|
66
|
+
"""Async verify using httpx. Requires: pip install xlock[fastapi]"""
|
|
67
|
+
import httpx
|
|
68
|
+
|
|
69
|
+
if token.startswith("v3."):
|
|
70
|
+
session_id = token.split(".")[1]
|
|
71
|
+
enforce_url = f"{api_url}/v3/session/enforce"
|
|
72
|
+
body = {"sessionId": session_id, "siteKey": site_key}
|
|
73
|
+
else:
|
|
74
|
+
enforce_url = f"{api_url}/v1/enforce"
|
|
75
|
+
body = {"token": token, "siteKey": site_key}
|
|
76
|
+
|
|
77
|
+
if path:
|
|
78
|
+
body["path"] = path
|
|
79
|
+
|
|
80
|
+
async with httpx.AsyncClient(timeout=5) as client:
|
|
81
|
+
res = await client.post(enforce_url, json=body)
|
|
82
|
+
|
|
83
|
+
if res.status_code == 403:
|
|
84
|
+
data = res.json()
|
|
85
|
+
return VerifyResult(blocked=True, reason=data.get("reason"))
|
|
86
|
+
|
|
87
|
+
if not res.is_success:
|
|
88
|
+
return VerifyResult(blocked=False, error=True)
|
|
89
|
+
|
|
90
|
+
return VerifyResult(blocked=False)
|