security: Secure cookie flag (ANIWEB_SECURE, on in run.sh) + kill sessions of deleted accounts
- middleware re-verifies the username against users.json per request: deleted accounts' signed cookies are rejected immediately - set_cookie/delete_cookie honor the Secure flag; run.sh sets ANIWEB_SECURE=1 so sessions only work through the HTTPS proxy
This commit is contained in:
1 parent
7e6db5a41c
commit
cdb25b1aba
4 files changed
+20
-5
No files matched your search
@@ -25,6 +25,12 @@ Account management (no self-signup by design):
|
|||||||
.venv/bin/python -m server.users list # list users
|
.venv/bin/python -m server.users list # list users
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Security: passwords are PBKDF2-SHA256 (210k iters, per-user salts) in
|
||||||
|
`server/data/users.json` (0600). Sessions are signed 30-day `HttpOnly`
|
||||||
|
`SameSite=Lax` cookies; `run.sh` sets `ANIWEB_SECURE=1` so cookies are
|
||||||
|
`Secure` and only work through your HTTPS proxy. Accounts deleted via
|
||||||
|
the app or CLI are rejected immediately, even with an old cookie.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Per-user accounts (CLI-created, no self-signup): 30-day signed
|
- Per-user accounts (CLI-created, no self-signup): 30-day signed
|
||||||
|
|||||||
@@ -3,5 +3,7 @@
|
|||||||
cd "$(dirname "$0")" || exit 1
|
cd "$(dirname "$0")" || exit 1
|
||||||
[ -x .venv/bin/python ] || { echo "no .venv — run: uv venv .venv && uv pip install --python .venv/bin/python -r requirements.txt"; exit 1; }
|
[ -x .venv/bin/python ] || { echo "no .venv — run: uv venv .venv && uv pip install --python .venv/bin/python -r requirements.txt"; exit 1; }
|
||||||
pkill -f "uvicorn .*app:app" 2>/dev/null; sleep 1
|
pkill -f "uvicorn .*app:app" 2>/dev/null; sleep 1
|
||||||
nohup .venv/bin/python -m uvicorn server.app:app --host 0.0.0.0 --port 8321 > server.log 2>&1 &
|
# ANIWEB_SECURE=1: session cookie is Secure — only usable via the HTTPS
|
||||||
echo "ani-web on http://localhost:8321 (pid $!)"
|
# proxy (plain http://<this machine>:8321 can no longer hold a session).
|
||||||
|
ANIWEB_SECURE=1 nohup .venv/bin/python -m uvicorn server.app:app --host 0.0.0.0 --port 8321 > server.log 2>&1 &
|
||||||
|
echo "ani-web on http://localhost:8321 (pid $!) — use the HTTPS proxy to log in"
|
||||||
+8
-3
@@ -27,6 +27,7 @@ _SIGNER = TimestampSigner(users.get_secret())
|
|||||||
_SESSION = "aniweb_session"
|
_SESSION = "aniweb_session"
|
||||||
_SESSION_AGE = 30 * 86400
|
_SESSION_AGE = 30 * 86400
|
||||||
_PUBLIC = {("/login", "GET"), ("/login", "POST")}
|
_PUBLIC = {("/login", "GET"), ("/login", "POST")}
|
||||||
|
_SECURE = os.environ.get("ANIWEB_SECURE") == "1" # set when served over HTTPS
|
||||||
|
|
||||||
# ponytail: in-memory lockout per username, single process — 5 fails -> 15 min
|
# ponytail: in-memory lockout per username, single process — 5 fails -> 15 min
|
||||||
_lockout: dict = {}
|
_lockout: dict = {}
|
||||||
@@ -59,6 +60,10 @@ async def require_auth(request, call_next):
|
|||||||
if (request.url.path, request.method) in _PUBLIC:
|
if (request.url.path, request.method) in _PUBLIC:
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
user = _session_user(request)
|
user = _session_user(request)
|
||||||
|
# stateless cookie -> re-verify the account still exists, so
|
||||||
|
# deleted users' cookies die immediately
|
||||||
|
if user is not None and user not in users.load():
|
||||||
|
user = None
|
||||||
if not user:
|
if not user:
|
||||||
if request.url.path.startswith("/api/"):
|
if request.url.path.startswith("/api/"):
|
||||||
return JSONResponse({"detail": "not authenticated"}, status_code=401)
|
return JSONResponse({"detail": "not authenticated"}, status_code=401)
|
||||||
@@ -70,7 +75,7 @@ async def require_auth(request, call_next):
|
|||||||
|
|
||||||
def _session_cookie(resp, user: str):
|
def _session_cookie(resp, user: str):
|
||||||
resp.set_cookie(_SESSION, _SIGNER.sign(b"user:" + user.encode()).decode(),
|
resp.set_cookie(_SESSION, _SIGNER.sign(b"user:" + user.encode()).decode(),
|
||||||
max_age=_SESSION_AGE, httponly=True, samesite="lax")
|
max_age=_SESSION_AGE, httponly=True, samesite="lax", secure=_SECURE)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/login", include_in_schema=False)
|
@app.get("/login", include_in_schema=False)
|
||||||
@@ -102,14 +107,14 @@ def api_me(request: Request):
|
|||||||
def delete_account(request: Request):
|
def delete_account(request: Request):
|
||||||
users.remove(request.state.user)
|
users.remove(request.state.user)
|
||||||
resp = JSONResponse({"ok": True})
|
resp = JSONResponse({"ok": True})
|
||||||
resp.delete_cookie(_SESSION)
|
resp.delete_cookie(_SESSION, secure=_SECURE)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@app.post("/logout")
|
@app.post("/logout")
|
||||||
def logout():
|
def logout():
|
||||||
resp = JSONResponse({"ok": True})
|
resp = JSONResponse({"ok": True})
|
||||||
resp.delete_cookie(_SESSION)
|
resp.delete_cookie(_SESSION, secure=_SECURE)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ def test_self_delete(base=BASE):
|
|||||||
jar = login("dave", "davepw12345", base)
|
jar = login("dave", "davepw12345", base)
|
||||||
assert req("POST", "/account/delete", jar=jar, base=base) == 200
|
assert req("POST", "/account/delete", jar=jar, base=base) == 200
|
||||||
assert "dave" not in json.load(open(_users_file()))
|
assert "dave" not in json.load(open(_users_file()))
|
||||||
|
# old session cookie is dead immediately (middleware re-checks existence)
|
||||||
|
assert req("GET", "/api/me", jar=jar, base=base) == 401
|
||||||
jar2 = CJ()
|
jar2 = CJ()
|
||||||
assert req("POST", "/login", {"username": "dave", "password": "davepw12345"}, jar2, base=base) == 401
|
assert req("POST", "/login", {"username": "dave", "password": "davepw12345"}, jar2, base=base) == 401
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user