Validate cadence contract and require functional MFA verification
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ea3-7939-7b63-8125-699f8b50bedd
This commit is contained in:
parent
d4d61b722e
commit
4e07d60ff1
34 changed files with 1640 additions and 364 deletions
128
sso-mfa/k8s/privacyidea/verify_mfa.py
Normal file
128
sso-mfa/k8s/privacyidea/verify_mfa.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue