Investment Plans workspace
Open raw ↗
"""Named local user accounts — passwords scrypt-hashed at rest (GOV-B7.1).

users.json is a FIXED file beside this module (server-side only; never served; the
name is a constant and every access re-validates containment with resolve() +
relative_to(), so the path can never escape the application folder). First run: an
admin account is created with a random password printed ONCE to the console, or
seeded from ADMIN_USER / ADMIN_PASS environment variables.
"""
import hashlib
import json
import os
import secrets
import time
from pathlib import Path

SCRYPT_N, SCRYPT_R, SCRYPT_P = 2 ** 14, 8, 1
BASE = Path(__file__).resolve().parent
STORE = BASE / "users.json"                  # constant name inside the app folder


def _contained(p: Path) -> Path:
    p = p.resolve()
    p.relative_to(BASE)                      # raises ValueError if it escapes the app folder
    return p


def hash_password(password: str, salt: bytes | None = None) -> str:
    salt = salt or secrets.token_bytes(16)
    digest = hashlib.scrypt(password.encode("utf-8"), salt=salt,
                            n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P, dklen=32)
    return salt.hex() + "$" + digest.hex()


def verify_password(password: str, stored: str) -> bool:
    try:
        salt_hex, digest_hex = stored.split("$", 1)
        candidate = hashlib.scrypt(password.encode("utf-8"), salt=bytes.fromhex(salt_hex),
                                   n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P, dklen=32)
        return secrets.compare_digest(candidate.hex(), digest_hex)
    except (ValueError, TypeError):
        return False


class UserStore:
    """{username: {"hash": ..., "created": ..., "failures": n, "locked_until": ts}}"""

    def __init__(self):
        self.users: dict = {}
        store = _contained(STORE)
        if store.exists():
            self.users = json.loads(store.read_text(encoding="utf-8"))

    def save(self) -> None:
        _contained(STORE).write_text(json.dumps(self.users, indent=2), encoding="utf-8")

    def ensure_admin(self) -> str | None:
        """Create the first admin if the store is empty; return the one-time password (env seed or random)."""
        if self.users:
            return None
        username = os.environ.get("ADMIN_USER", "admin")
        password = os.environ.get("ADMIN_PASS") or secrets.token_urlsafe(12)
        self.users[username] = {"hash": hash_password(password), "created": time.time(),
                                "failures": 0, "locked_until": 0}
        self.save()
        return password

    def check(self, username: str, password: str) -> bool:
        rec = self.users.get(username)
        if not rec:
            return False
        if rec.get("locked_until", 0) > time.time():
            return False
        if verify_password(password, rec["hash"]):
            rec["failures"], rec["locked_until"] = 0, 0
            self.save()
            return True
        rec["failures"] = rec.get("failures", 0) + 1
        if rec["failures"] >= 5:                      # simple lockout: 5 fails -> 60 s
            rec["locked_until"] = time.time() + 60
            rec["failures"] = 0
        self.save()
        return False

    def has_plaintext(self) -> bool:
        """True if any record looks like an unhashed password (a defect if ever true)."""
        for rec in self.users.values():
            if "$" not in str(rec.get("hash", "")):
                return True
        return False