Updated backend auth settings
This commit is contained in:
@@ -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.<domain> - 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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
37
test_login_fix.py
Normal file
37
test_login_fix.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user