"""Attended functional privacyIDEA proof for verify-t06.sh.""" import argparse import getpass import json import sys import urllib.parse from pi_api import request def value(status, body): if status != 200 or not isinstance(body, dict): raise ValueError("request failed") result = body.get("result") if not isinstance(result, dict) or result.get("status") is not True: raise ValueError("API operation failed") return result.get("value") def verify(url, user, realm, resolver, password, otp_prompt): phase = "authentication" try: auth = value( *request(url + "/auth", {"username": "pi-admin", "password": password}) ) token = auth.get("token") if isinstance(auth, dict) else None if not isinstance(token, str) or not token: raise ValueError("admin token missing") phase = "realm-binding" realms = value(*request(url + "/realm/", token=token)) bindings = realms[realm]["resolver"] if not any( isinstance(item, dict) and item.get("name") == resolver for item in bindings ): raise ValueError("realm not bound to expected resolver") phase = "resolver-tuning" resolvers = value( *request( url + "/resolver/" + urllib.parse.quote(resolver, safe=""), token=token ) ) config = resolvers[resolver] if config.get("type") != "ldapresolver": raise ValueError("wrong resolver type") data = config["data"] for name in ("TIMEOUT", "CACHE_TIMEOUT", "SIZELIMIT"): number = data.get(name) if ( isinstance(number, bool) or not isinstance(number, (str, int)) or not str(number).isascii() or not str(number).isdigit() ): raise ValueError("missing or invalid numeric parameter") phase = "known-user-lookup" query = urllib.parse.urlencode({"realm": realm, "username": user}) users = value(*request(url + "/user/?" + query, token=token)) if not isinstance(users, list) or not any( isinstance(item, dict) and item.get("username") == user and item.get("resolver") == resolver for item in users ): raise ValueError("known user did not resolve through expected resolver") phase = "mfa-validation" otp = otp_prompt() if not otp: raise ValueError("MFA input missing") status, body = request( url + "/validate/check", {"user": user, "realm": realm, "pass": otp}, token ) # passthru can return value=true for token-less users. Require a token # serial and type in the successful validation, not password-only success. if value(status, body) is not True: raise ValueError("MFA denied") detail = body.get("detail", {}) if not detail.get("serial") or detail.get("type") not in {"totp", "hotp"}: raise ValueError("no token-backed MFA proof") except (OSError, ValueError, TypeError, KeyError, AttributeError, EOFError): return {"result": "FAIL", "phase": phase} return { "result": "PASS", "phase": "complete", "proofs": [ "realm-binding", "resolver-tuning", "known-user-lookup", "token-backed-mfa", ], } def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--pi-url", default="https://pink.coulomb.social") parser.add_argument("--user", required=True) parser.add_argument("--realm", default="coulomb") parser.add_argument("--resolver", default="lldap-coulomb") args = parser.parse_args(argv) if not sys.stdin.isatty(): print( "T06 requires an attended terminal for protected password and fresh MFA input.", file=sys.stderr, ) return 2 if urllib.parse.urlsplit(args.pi_url).scheme != "https": parser.error("--pi-url must use HTTPS") try: password = getpass.getpass("privacyIDEA pi-admin password: ") if not password: raise ValueError("empty password") report = verify( args.pi_url.rstrip("/"), args.user, args.realm, args.resolver, password, lambda: getpass.getpass("Fresh MFA code (include token PIN if required): "), ) except (EOFError, KeyboardInterrupt, ValueError): report = {"result": "FAIL", "phase": "protected-input"} print(json.dumps(report, sort_keys=True)) return 0 if report["result"] == "PASS" else 1 if __name__ == "__main__": raise SystemExit(main())