feat(auth): make access-token TTL configurable, default 4h
Replace the hardcoded 1440-minute (24h) JWT lifetime with DECNET_JWT_EXP_MINUTES (validated positive int, default 240 = 4h). Shrinks the passive window of a stolen token; active revocation is unchanged (immediate->=<10s).
This commit is contained in:
@@ -28,6 +28,17 @@ def _port(name: str, default: int) -> int:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _pos_int(name: str, default: int) -> int:
|
||||||
|
raw = os.environ.get(name, str(default))
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Environment variable '{name}' must be an integer, got '{raw}'.")
|
||||||
|
if value < 1:
|
||||||
|
raise ValueError(f"Environment variable '{name}' must be a positive integer, got {value}.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _require_env(name: str) -> str:
|
def _require_env(name: str) -> str:
|
||||||
"""Return the env var value or raise at startup if it is unset or a known-bad default."""
|
"""Return the env var value or raise at startup if it is unset or a known-bad default."""
|
||||||
_KNOWN_BAD = {"fallback-secret-key-change-me", "admin", "secret", "password", "changeme"}
|
_KNOWN_BAD = {"fallback-secret-key-change-me", "admin", "secret", "password", "changeme"}
|
||||||
@@ -138,6 +149,11 @@ DECNET_BATCH_MAX_WAIT_MS: int = int(os.environ.get("DECNET_BATCH_MAX_WAIT_MS", "
|
|||||||
DECNET_WEB_HOST: str = os.environ.get("DECNET_WEB_HOST", "127.0.0.1")
|
DECNET_WEB_HOST: str = os.environ.get("DECNET_WEB_HOST", "127.0.0.1")
|
||||||
DECNET_WEB_PORT: int = _port("DECNET_WEB_PORT", 8080)
|
DECNET_WEB_PORT: int = _port("DECNET_WEB_PORT", 8080)
|
||||||
DECNET_ADMIN_USER: str = os.environ.get("DECNET_ADMIN_USER", "admin")
|
DECNET_ADMIN_USER: str = os.environ.get("DECNET_ADMIN_USER", "admin")
|
||||||
|
# Access-token lifetime in minutes. Default 4h — short enough to bound the
|
||||||
|
# passive window of a stolen token (active revocation is immediate→≤10s), long
|
||||||
|
# enough that the operator is not re-authenticating constantly. Shortening this
|
||||||
|
# meaningfully (e.g. 15m) requires a refresh-token mechanism; deferred to v1.
|
||||||
|
DECNET_JWT_EXP_MINUTES: int = _pos_int("DECNET_JWT_EXP_MINUTES", 240)
|
||||||
# DECNET_ADMIN_PASSWORD is resolved lazily via __getattr__ (like DECNET_JWT_SECRET)
|
# DECNET_ADMIN_PASSWORD is resolved lazily via __getattr__ (like DECNET_JWT_SECRET)
|
||||||
# so it is validated only on the master processes that seed the admin user, and
|
# so it is validated only on the master processes that seed the admin user, and
|
||||||
# never silently defaults to "admin". See _require_env + __getattr__ below.
|
# never silently defaults to "admin". See _require_env + __getattr__ below.
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ from typing import Optional, Any
|
|||||||
import jwt
|
import jwt
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|
||||||
from decnet.env import DECNET_JWT_SECRET
|
from decnet.env import DECNET_JWT_SECRET, DECNET_JWT_EXP_MINUTES
|
||||||
|
|
||||||
SECRET_KEY: str = DECNET_JWT_SECRET
|
SECRET_KEY: str = DECNET_JWT_SECRET
|
||||||
ALGORITHM: str = "HS256"
|
ALGORITHM: str = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = DECNET_JWT_EXP_MINUTES
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ DECNET_API_PORT=8000
|
|||||||
# is true. Known-bad values (admin, secret, password, changeme,
|
# is true. Known-bad values (admin, secret, password, changeme,
|
||||||
# fallback-secret-key-change-me) are rejected at startup.
|
# fallback-secret-key-change-me) are rejected at startup.
|
||||||
DECNET_JWT_SECRET=
|
DECNET_JWT_SECRET=
|
||||||
|
# Access-token lifetime in minutes (positive integer). Default 240 (4h).
|
||||||
|
# Bounds how long a stolen token stays valid passively; active revocation
|
||||||
|
# (logout, password/role change) is immediate→≤10s regardless. Going much
|
||||||
|
# lower (e.g. 15) needs a refresh-token flow — not yet implemented.
|
||||||
|
DECNET_JWT_EXP_MINUTES=240
|
||||||
# File the ingester tails for honeypot events.
|
# File the ingester tails for honeypot events.
|
||||||
DECNET_INGEST_LOG_FILE=/var/log/decnet/decnet.log
|
DECNET_INGEST_LOG_FILE=/var/log/decnet/decnet.log
|
||||||
|
|
||||||
|
|||||||
59
tests/web/test_jwt_exp_config.py
Normal file
59
tests/web/test_jwt_exp_config.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
"""Access-token lifetime is configurable via DECNET_JWT_EXP_MINUTES, defaults
|
||||||
|
to 4h, and rejects non-positive / non-integer values at import time."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _reimport_env(monkeypatch, value: str | None):
|
||||||
|
if value is None:
|
||||||
|
monkeypatch.delenv("DECNET_JWT_EXP_MINUTES", raising=False)
|
||||||
|
else:
|
||||||
|
monkeypatch.setenv("DECNET_JWT_EXP_MINUTES", value)
|
||||||
|
for mod in list(sys.modules):
|
||||||
|
if mod == "decnet.env" or mod.startswith("decnet.env."):
|
||||||
|
sys.modules.pop(mod)
|
||||||
|
return importlib.import_module("decnet.env")
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_is_four_hours(monkeypatch):
|
||||||
|
env = _reimport_env(monkeypatch, None)
|
||||||
|
assert env.DECNET_JWT_EXP_MINUTES == 240
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_is_honored(monkeypatch):
|
||||||
|
env = _reimport_env(monkeypatch, "30")
|
||||||
|
assert env.DECNET_JWT_EXP_MINUTES == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_integer_rejected(monkeypatch):
|
||||||
|
with pytest.raises(ValueError, match="must be an integer"):
|
||||||
|
_reimport_env(monkeypatch, "soon")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["0", "-5"])
|
||||||
|
def test_non_positive_rejected(monkeypatch, bad):
|
||||||
|
with pytest.raises(ValueError, match="positive integer"):
|
||||||
|
_reimport_env(monkeypatch, bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_module_tracks_env(monkeypatch):
|
||||||
|
"""decnet.web.auth.ACCESS_TOKEN_EXPIRE_MINUTES reflects the env var."""
|
||||||
|
def _drop():
|
||||||
|
for mod in ("decnet.env", "decnet.web.auth"):
|
||||||
|
sys.modules.pop(mod, None)
|
||||||
|
|
||||||
|
monkeypatch.setenv("DECNET_JWT_EXP_MINUTES", "45")
|
||||||
|
monkeypatch.setenv("DECNET_JWT_SECRET", "x" * 32)
|
||||||
|
_drop()
|
||||||
|
try:
|
||||||
|
auth = importlib.import_module("decnet.web.auth")
|
||||||
|
assert auth.ACCESS_TOKEN_EXPIRE_MINUTES == 45
|
||||||
|
finally:
|
||||||
|
# Don't leak a 45-minute auth module into the rest of the suite — force
|
||||||
|
# a clean rebuild from the (monkeypatch-restored) environment.
|
||||||
|
_drop()
|
||||||
Reference in New Issue
Block a user