Production Login page passcode updates
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user