Production Login page passcode updates

This commit is contained in:
sriram
2026-08-24 22:55:06 +05:30
parent 2482fe43e3
commit 1b347f91db
11 changed files with 878 additions and 67 deletions

View File

@@ -4,6 +4,12 @@
# ./scripts/check_deploy.sh # https://mcp.nearle.ai.in
# ./scripts/check_deploy.sh http://localhost:3000 # a local container
#
# ADMIN_PASSWORD=... ./scripts/check_deploy.sh # also prove sign-in WORKS
#
# ADMIN_PASSWORD is read from the environment and never stored here: a committed
# smoke test must not carry a live credential. Without it the positive sign-in
# check is skipped with a WARN rather than failing.
#
# Separates the three failures that all look alike from a browser:
# 502 everywhere - the container is not running (it exited at startup)
# 200 + database:false - the API is fine, Postgres is not
@@ -70,6 +76,90 @@ elif [ "$code" = "200" ]; then
fail=$((fail+1))
else printf ' \033[31mFAIL\033[0m login endpoint returned %s\n' "$code"; fail=$((fail+1)); fi
# The complementary half, and the one that actually catches a bad deploy. The
# check above passes just as happily when NOBODY can sign in - which is exactly
# the outage this script failed to notice: prod rejected the correct password
# while every check here stayed green. Note the negative check runs FIRST on
# purpose, since a successful login calls _clear_failures() and would otherwise
# hand the wrong-password probe a fresh throttle bucket.
if [ -n "${ADMIN_PASSWORD:-}" ]; then
code=$(curl -sS -m 20 -o /tmp/_login -w '%{http_code}' -X POST "$BASE/api/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"username\":\"${ADMIN_USERNAME:-admin}\",\"password\":\"$ADMIN_PASSWORD\"}" 2>/dev/null)
if [ "$code" = "200" ] && grep -q access_token /tmp/_login 2>/dev/null; then
printf ' \033[32mPASS\033[0m correct password accepted (200 + token)\n'; pass=$((pass+1))
elif [ "$code" = "429" ]; then
printf ' \033[33mWARN\033[0m throttled (429) by the earlier failed attempt, not a\n'
echo " credential problem. Wait AUTH_LOCKOUT_SECONDS and rerun."
elif [ "$code" = "401" ]; then
printf ' \033[31mFAIL\033[0m the CORRECT password was rejected (401).\n'
echo " This deployment is not carrying the credential you think it is."
echo " The fingerprint check below says which way; the server log names"
echo " the reason - grep it for 'reason=' and for 'Auth config:'."
fail=$((fail+1))
else
printf ' \033[31mFAIL\033[0m login with the correct password returned %s\n' "$code"; fail=$((fail+1))
fi
rm -f /tmp/_login
else
printf ' \033[33mWARN\033[0m sign-in not proven to WORK - set ADMIN_PASSWORD to check that.\n'
echo " Rejecting a wrong password is only half the test; an image with a"
echo " stale hash passes that half while nobody can sign in."
fi
# Which credential is this deployment actually running? The fingerprint is a
# digest of the configured hash, so comparing it against the local file settles
# "is my config live?" without either side revealing a secret.
fp=$(printf '%s' "$health" | sed -n 's/.*"password_hash_fingerprint":"\([a-z0-9]*\)".*/\1/p')
hash_ok=$(printf '%s' "$health" | sed -n 's/.*"password_hash_valid":\([a-z]*\).*/\1/p')
hsrc=$(printf '%s' "$health" | sed -n 's/.*"password_hash_source":"\([a-z-]*\)".*/\1/p')
if [ -z "$fp" ]; then
printf ' \033[33mWARN\033[0m no auth diagnostics in /api/health - this deployment predates\n'
echo " them and needs a rebuild before it can be compared."
else
printf ' \033[32mPASS\033[0m credential fingerprint %s (from %s)\n' "$fp" "$hsrc"; pass=$((pass+1))
if [ "$hash_ok" = "false" ]; then
printf ' \033[31mFAIL\033[0m the configured password hash does not parse. NO password can\n'
echo " match it. Regenerate: python scripts/make_auth_secrets.py"
fail=$((fail+1))
fi
local_env="$(dirname "$0")/../.env.production"
# Probe interpreters by RUNNING one, not with `command -v`: on Windows,
# /c/.../WindowsApps/python3 is an App Store stub that resolves fine, prints a
# "Python was not found" notice and exits 0. Trusting the lookup made this
# comparison skip silently - which reads as "checked, matches", the exact kind
# of quiet pass this script exists to eliminate.
py=""
for candidate in python3 python py; do
if [ "$("$candidate" -c 'print(1)' 2>/dev/null)" = "1" ]; then py="$candidate"; break; fi
done
if [ ! -f "$local_env" ]; then
:
elif [ -z "$py" ]; then
printf ' \033[33mWARN\033[0m no working python on PATH - cannot compare the deployment\n'
echo " against local .env.production."
else
local_fp=$("$py" "$(dirname "$0")/make_auth_secrets.py" --fingerprint "$local_env" 2>/dev/null | sed -n 's/^fingerprint *: *//p')
if [ -z "$local_fp" ]; then
printf ' \033[33mWARN\033[0m could not read a fingerprint out of %s\n' "$local_env"
elif [ "$local_fp" = "$fp" ]; then
printf ' \033[32mPASS\033[0m matches local .env.production\n'; pass=$((pass+1))
else
printf ' \033[31mFAIL\033[0m local .env.production is %s, deployment is %s\n' "$local_fp" "$fp"
if [ "$hsrc" = "process-env" ]; then
echo " It came from the process environment, which OVERRIDES the .env"
echo " baked into the image. Clear AUTH_ADMIN_PASSWORD_HASH from the"
echo " deployment platform's Environment tab."
else
echo " It came from the image's own .env, so that image was built from a"
echo " different .env.production. This needs a REBUILD - the Dockerfile"
echo " copies the file at BUILD time, so a restart will not pick it up."
fi
fail=$((fail+1))
fi
fi
fi
echo
echo "MCP:"
code=$(curl -sS -m 20 -o /dev/null -w '%{http_code}' "$BASE/api/mcp/info" 2>/dev/null)

View File

@@ -4,6 +4,16 @@ Generate the authentication secrets that backend/.env needs.
python scripts/make_auth_secrets.py # random passwords
python scripts/make_auth_secrets.py --admin-password 'my pass' --user-password 'other'
It also answers the opposite question - "which credential is this deployment
actually running?" - without revealing one:
python scripts/make_auth_secrets.py --fingerprint .env.production
python scripts/make_auth_secrets.py --fingerprint .env.production --verify-password 'my pass'
The fingerprint printed there is the same value /api/health reports as
`auth.password_hash_fingerprint`, so a mismatch between the two says outright
that the running image is not using the file you are looking at.
Prints .env lines ready to paste. Passwords are shown once, on stdout only -
they are not written anywhere, because only their PBKDF2 digest is stored. If
you lose one, rerun this and replace the hash.
@@ -17,8 +27,11 @@ from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import secrets
import string
import sys
from pathlib import Path
_PBKDF2_ITERATIONS = 600_000
@@ -42,6 +55,130 @@ def hash_password(password: str, *, iterations: int = _PBKDF2_ITERATIONS) -> str
)
def fingerprint(encoded_hash: str) -> str:
"""Must stay byte-compatible with security.password_hash_fingerprint."""
return hashlib.sha256(
encoded_hash.strip().strip("'\"").encode("utf-8")
).hexdigest()[:12]
def verify_password(password: str, encoded: str) -> bool:
"""Must stay byte-compatible with security.verify_password."""
try:
prefix, raw_iterations, raw_salt, raw_digest = (
encoded.strip().strip("'\"").split("$")
)
if prefix != "pbkdf2_sha256":
return False
salt = base64.b64decode(raw_salt)
expected = base64.b64decode(raw_digest)
iterations = int(raw_iterations)
except (ValueError, TypeError):
return False
candidate = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return hmac.compare_digest(candidate, expected)
def read_env_value(path: Path, key: str) -> str:
"""
Pull one KEY=value out of a .env file.
Hand-parsed rather than via python-dotenv because this script deliberately
depends on nothing (see the module docstring): the one workflow it has to
survive is a checkout whose configuration is too broken to import.
"""
if not path.is_file():
raise SystemExit(f"No such file: {path}")
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if line.startswith(f"{key}=") and not line.startswith("#"):
return line.split("=", 1)[1].strip().strip("'\"")
return ""
def remote_fingerprint(base_url: str) -> str:
"""
Ask a running deployment which credential it loaded, via /api/health.
urllib rather than requests, because this script must keep working in a
checkout with nothing installed - that is the whole reason it imports
nothing from `app`.
"""
import json
import urllib.request
url = base_url.rstrip("/") + "/api/health"
try:
with urllib.request.urlopen(url, timeout=20) as resp:
payload = json.loads(resp.read().decode("utf-8"))
except Exception as exc: # noqa: BLE001 - any failure here is just "unreachable"
raise SystemExit(f"Could not read {url}: {exc}")
auth = payload.get("auth")
if not isinstance(auth, dict) or "password_hash_fingerprint" not in auth:
raise SystemExit(
f"{url} answered, but carries no auth diagnostics. That deployment "
"predates this feature - it needs a rebuild before it can be compared."
)
return auth
def report_fingerprint(env_file: str, password: str | None, url: str | None = None) -> None:
path = Path(env_file)
encoded = read_env_value(path, "AUTH_ADMIN_PASSWORD_HASH")
username = read_env_value(path, "AUTH_ADMIN_USERNAME") or "admin"
if not encoded:
raise SystemExit(f"{path}: no AUTH_ADMIN_PASSWORD_HASH set")
print(f"file : {path}")
print(f"username : {username}")
print(f"fingerprint : {fingerprint(encoded)}")
if password is not None:
ok = verify_password(password, encoded)
print(f"verifies : {ok}")
if not ok:
print()
print("The password given does NOT match the hash in this file.")
sys.exit(1)
if url is None:
print()
print("Compare with the running deployment:")
print(" curl -s <api-base>/api/health | jq -r .auth.password_hash_fingerprint")
print(" (or rerun this with --url <api-base>)")
print("A different value means it is not running this file's credential.")
return
remote = remote_fingerprint(url)
print()
print(f"deployment : {url}")
print(f" username : {remote.get('admin_username')!r}")
print(f" fingerprint : {remote.get('password_hash_fingerprint')}")
print(f" hash valid : {remote.get('password_hash_valid')}")
print(f" came from : {remote.get('password_hash_source')}")
print()
if remote.get("password_hash_fingerprint") == fingerprint(encoded):
print("MATCH - the deployment is running this file's credential.")
if remote.get("admin_username") != username:
print(f"But the usernames differ: {username!r} here, "
f"{remote.get('admin_username')!r} there.")
sys.exit(1)
return
print("MISMATCH - the deployment is NOT running this file's credential.")
if remote.get("password_hash_source") == "process-env":
print(" Its value came from the process environment, which overrides the")
print(" .env baked into the image. Clear AUTH_ADMIN_PASSWORD_HASH from the")
print(" deployment platform's Environment tab.")
else:
print(" Its value came from the image's own .env, so that image was built")
print(" from a different .env.production. This needs a REBUILD - the")
print(" Dockerfile copies the file at build time, so restarting will not")
print(" pick up a corrected value.")
sys.exit(1)
def generate_password(length: int = 20) -> str:
return "".join(secrets.choice(_ALPHABET) for _ in range(length))
@@ -57,8 +194,37 @@ def main() -> None:
default=[],
help="Also mint an API_KEYS entry, e.g. --api-key partner-x:user (repeatable)",
)
parser.add_argument(
"--fingerprint",
metavar="ENV_FILE",
help=(
"Don't generate anything - report the fingerprint of the "
"AUTH_ADMIN_PASSWORD_HASH already in this .env file, for comparison "
"against /api/health on a running deployment."
),
)
parser.add_argument(
"--verify-password",
metavar="PASSWORD",
help="With --fingerprint: check this password against that file's hash.",
)
parser.add_argument(
"--url",
metavar="API_BASE",
help=(
"With --fingerprint: also read /api/health from a running deployment "
"and report whether it carries this file's credential. Exits non-zero "
"on a mismatch, so it composes into scripts/check_deploy.sh."
),
)
args = parser.parse_args()
if args.fingerprint:
report_fingerprint(args.fingerprint, args.verify_password, args.url)
return
if args.verify_password or args.url:
parser.error("--verify-password and --url only make sense with --fingerprint")
admin_password = args.admin_password or generate_password()
user_password = args.user_password or generate_password()