From b8d93fbbf29bb4914977365cb716d9332983123b Mon Sep 17 00:00:00 2001 From: sriram Date: Wed, 12 Aug 2026 18:10:25 +0530 Subject: [PATCH] Updated backend auth settings --- .env.example | 7 ++++ app/api/routers/auth.py | 62 +++++++++++++++++++++++++--------- app/infrastructure/settings.py | 16 +++++++++ test_login_fix.py | 37 ++++++++++++++++++++ 4 files changed, 106 insertions(+), 16 deletions(-) create mode 100644 test_login_fix.py diff --git a/.env.example b/.env.example index 124e3b5..4046cf4 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,13 @@ AUTH_TOKEN_TTL_MINUTES=720 AUTH_MAX_LOGIN_ATTEMPTS=10 AUTH_LOCKOUT_SECONDS=300 +# Local development only: accept ANY password at /api/auth/login. The username +# still picks the role (`admin` -> admin pages, `user` -> user pages), and the +# token issued is a normal one, so every other guard behaves normally. Unlike +# AUTH_ENABLED=false it leaves the login page working - it just stops checking +# the password. Anyone who can reach the port becomes admin: keep it false here. +AUTH_ALLOW_ANY_LOGIN=false + # Machine consumers of api. - scripts, partner integrations, your own # backends. Format: name:role:secret, comma-separated, role is admin or user. # Callers send the secret as an X-API-Key header. diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index 1a64174..e243c37 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -14,6 +14,10 @@ user table, no registration flow and no password reset, and inventing one here would be a bigger change than the problem calls for. Machine consumers get API_KEYS instead. If per-user accounts become a real requirement, this module is the seam to replace. + +For local work there is AUTH_ALLOW_ANY_LOGIN, which skips the password check +here and nowhere else - the token still gets signed and every guard downstream +still checks it. It is off by default and logs a warning at startup when on. """ from __future__ import annotations @@ -35,6 +39,7 @@ from app.infrastructure.security import ( from app.infrastructure.settings import ( AUTH_ADMIN_PASSWORD_HASH, AUTH_ADMIN_USERNAME, + AUTH_ALLOW_ANY_LOGIN, AUTH_ENABLED, AUTH_LOCKOUT_SECONDS, AUTH_MAX_LOGIN_ATTEMPTS, @@ -45,6 +50,13 @@ from app.infrastructure.settings import ( logger = logging.getLogger(__name__) router = APIRouter(prefix="/auth", tags=["auth"]) +if AUTH_ENABLED and AUTH_ALLOW_ANY_LOGIN: + logger.warning( + "AUTH_ALLOW_ANY_LOGIN=true: /api/auth/login accepts ANY password, so anyone " + "who can reach this port can sign in as admin. Local development only - " + "set it to false in backend/.env before exposing this server." + ) + class LoginRequest(BaseModel): username: str = Field(min_length=1, max_length=150) @@ -168,24 +180,42 @@ def login(payload: LoginRequest, request: Request) -> LoginResponse: username = payload.username.strip().lower() key = _throttle_key(username, request) - _check_not_locked(key) - account = _accounts().get(username) - - # Verify against a dummy hash when the username is unknown so a bad - # username and a bad password take the same time. Otherwise the response - # latency alone enumerates valid usernames. - stored_hash = account["password_hash"] if account else _DUMMY_HASH - password_ok = verify_password(payload.password, stored_hash) - - if account is None or not password_ok: - _record_failure(key) - logger.warning("Failed sign-in for %r from %s", username, key[1]) - # One message for both failure modes, for the same reason. - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid username or password.", + if AUTH_ALLOW_ANY_LOGIN: + # Dev bypass: any password gets in. The username still picks the + # account, so `admin` lands on the admin pages and `user` on the user + # ones; anything else is an unconfigured name and gets the lower of the + # two roles rather than silently minting an admin. Throttling is skipped + # because there is no longer a password to guess. + account = _accounts().get(username) or { + "role": "user", + "display_name": payload.username.strip() or username, + "email": f"{username}@nutritionintel.com", + } + logger.warning( + "AUTH_ALLOW_ANY_LOGIN: signing in %r as %s without checking the password", + username, + account["role"], ) + else: + _check_not_locked(key) + + account = _accounts().get(username) + + # Verify against a dummy hash when the username is unknown so a bad + # username and a bad password take the same time. Otherwise the response + # latency alone enumerates valid usernames. + stored_hash = account["password_hash"] if account else _DUMMY_HASH + password_ok = verify_password(payload.password, stored_hash) + + if account is None or not password_ok: + _record_failure(key) + logger.warning("Failed sign-in for %r from %s", username, key[1]) + # One message for both failure modes, for the same reason. + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password.", + ) _clear_failures(key) role = account["role"] diff --git a/app/infrastructure/settings.py b/app/infrastructure/settings.py index 4e3be6e..b180edb 100644 --- a/app/infrastructure/settings.py +++ b/app/infrastructure/settings.py @@ -180,6 +180,22 @@ AUTH_USER_PASSWORD_HASH = ( AUTH_MAX_LOGIN_ATTEMPTS = int(os.getenv("AUTH_MAX_LOGIN_ATTEMPTS", "10")) AUTH_LOCKOUT_SECONDS = int(os.getenv("AUTH_LOCKOUT_SECONDS", "300")) +# Local-development escape hatch: accept ANY password at /api/auth/login, so a +# developer who does not have the configured passwords to hand can still reach +# the admin and user pages. The username still selects the role, and the token +# issued is a normal signed one - so every downstream guard, /api/auth/me, and +# the React route gating all behave exactly as they do in production. What is +# skipped is only the password check. +# +# This is NOT the same as AUTH_ENABLED=false. That disables every guard *and* +# makes /api/auth/login return 503, which breaks the login page outright. This +# flag keeps the whole auth machinery running and unlocks just the front door. +# +# Anyone who can reach the API can sign in as admin while it is on. Keep it +# false anywhere the port is reachable by someone you would not hand the admin +# password to. +AUTH_ALLOW_ANY_LOGIN = _bool("AUTH_ALLOW_ANY_LOGIN", "false") + def _parse_api_keys(raw: str) -> dict: """ diff --git a/test_login_fix.py b/test_login_fix.py new file mode 100644 index 0000000..64d0cee --- /dev/null +++ b/test_login_fix.py @@ -0,0 +1,37 @@ +"""Quick smoke test: can the passwords in .env be verified? (No app imports.)""" +import base64, hashlib, hmac, os +from pathlib import Path +from dotenv import load_dotenv + +load_dotenv(Path(__file__).parent / ".env", override=True) + +def verify_password(password, encoded): + try: + prefix, raw_iters, raw_salt, raw_digest = encoded.split("$") + if prefix != "pbkdf2_sha256": + return False + salt = base64.b64decode(raw_salt) + expected = base64.b64decode(raw_digest) + iterations = int(raw_iters) + except (ValueError, TypeError): + return False + candidate = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) + return hmac.compare_digest(candidate, expected) + +admin_hash = os.environ["AUTH_ADMIN_PASSWORD_HASH"] +user_hash = os.environ["AUTH_USER_PASSWORD_HASH"] + +print(f"Admin hash prefix: {admin_hash[:20]!r}") +print(f"User hash prefix: {user_hash[:20]!r}") + +ok1 = verify_password("DevAdmin!2026", admin_hash) +ok2 = verify_password("DevUser!2026", user_hash) + +print(f"Admin password verify: {ok1}") +print(f"User password verify: {ok2}") + +if ok1 and ok2: + print("\nSUCCESS - both passwords verify. Login will work.") +else: + print("\nFAILURE - one or both passwords do NOT verify.") + exit(1)