#!/usr/bin/env python3 """한 사람의 2차 인증 수단을 보고, 필요하면 지웁니다 (운영 매뉴얼 M9). 🟢 기본은 읽기만 합니다. 지우는 것은 --delete 를 --apply 와 함께 줄 때뿐입니다. 표준 라이브러리만 씁니다 — 설치할 것이 없습니다. export CF_API_TOKEN=<토큰> # 읽기: Access Read · 지우기: Access Write export CF_ACCOUNT_ID=<계정 ID> python mfa.py --email someone@example.com python mfa.py --email someone@example.com --delete <인증기 id> --apply 🔴 **목록 API 가 없습니다.** 이 목록은 `last_seen_identity` 안의 `mfa_authenticators` 이고, 그것은 「마지막 로그인 순간에 찍힌 사진」입니다. 그 뒤에 등록한 수단은 **안 보입니다** — 그래서 이 스크립트는 개수를 단정하지 않고 **찍힌 시각을 함께** 냅니다. 한 번 더 로그인하면 갱신됩니다. 🔴 **지우는 것과 등록해 주는 것은 다릅니다.** 관리자가 대신 등록해 줄 수는 없습니다 — 지우면 그 사람이 다음 접속에 「Setup MFA」 화면을 받아 본인이 다시 QR 을 찍습니다. 권하는 예방 — 사용자마다 수단 2개 이상(인증 앱 + 보안키/생체). 그러면 하나를 잃어도 본인이 App Launcher › Account 에서 직접 정리할 수 있고 관리자가 아예 필요 없습니다. 공식 문서 Independent MFA https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/independent-mfa/ 마지막 신원 https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/users/subresources/last_seen_identity/methods/get/ 사용자 목록 https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/users/methods/list/ """ import argparse import json import os import sys import urllib.error import urllib.parse import urllib.request API = "https://api.cloudflare.com/client/v4" TOKEN = os.environ.get("CF_API_TOKEN", "") ACCOUNT = os.environ.get("CF_ACCOUNT_ID", "") if not TOKEN or not ACCOUNT: sys.exit("CF_API_TOKEN 과 CF_ACCOUNT_ID 를 환경변수로 넣어 주세요.") def call(method, path, params=None): url = API + "/accounts/" + ACCOUNT + path if params: url += "?" + urllib.parse.urlencode(params) req = urllib.request.Request(url, method=method) req.add_header("Authorization", "Bearer " + TOKEN) try: with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read().decode("utf-8")) except urllib.error.HTTPError as e: raw = e.read().decode("utf-8", "replace") try: msg = json.dumps(json.loads(raw).get("errors"), ensure_ascii=False) except ValueError: msg = raw[:200] sys.exit("%s %s → HTTP %d %s" % (method, path, e.code, msg)) def get_all(path): out, cursor = [], "" for _ in range(50): q = {"per_page": "100"} if cursor: q["cursor"] = cursor j = call("GET", path, q) batch = j.get("result") or [] out.extend(batch) nxt = (j.get("result_info") or {}).get("cursor") or "" if not nxt or nxt == cursor or not batch: break cursor = nxt return out ap = argparse.ArgumentParser(description="한 사람의 2차 인증 수단 (M9)") ap.add_argument("--email", required=True) ap.add_argument("--delete", metavar="ID", help="지울 인증기 id") ap.add_argument("--apply", action="store_true", help="이것이 없으면 지우지 않습니다") a = ap.parse_args() want = a.email.strip().lower() user = None for u in get_all("/access/users"): if str(u.get("email") or "").strip().lower() == want: user = u break if not user: sys.exit("그 주소는 이 계정의 Zero Trust 사용자 목록에 없습니다: " + a.email) print("사용자 %s" % user.get("email")) print(" id %s" % user.get("id")) print(" 마지막 로그인 %s" % (user.get("last_successful_login") or "-")) seen = (call("GET", "/access/users/%s/last_seen_identity" % user.get("id")) or {}).get("result") or {} auth = seen.get("mfa_authenticators") or [] print("\n찍힌 시각 %s ← 이 시점의 사진입니다" % (seen.get("iat") or user.get("last_successful_login") or "-")) print("등록된 수단 %d개" % len(auth)) for m in auth: print(" %-38s %-14s %s" % ( str(m.get("id") or "-")[:38], str(m.get("type") or "-")[:14], str(m.get("name") or "")[:30])) if not auth: print(" (이 사진에는 없습니다 — 한 번도 등록하지 않았거나, **이 로그인 뒤에 등록**했을 수 있습니다)") if not a.delete: print("\n지우려면 --delete 를 --apply 와 함께 주십시오.") print("지운 뒤 그 사람은 다음 접속에 「Setup MFA」 를 받고 본인이 다시 등록합니다.") sys.exit(0) if not a.apply: print("\n미리보기입니다(--apply 없음). 보낼 요청:") print(" DELETE /accounts/%s/access/users/%s/mfa_authenticators/%s" % (ACCOUNT, user.get("id"), a.delete)) sys.exit(0) if auth and not any(str(m.get("id")) == a.delete for m in auth): print("\n⚠️ 그 id 는 이 사진에 없습니다 — 사진이 낡았을 수 있어 그대로 보냅니다.") call("DELETE", "/access/users/%s/mfa_authenticators/%s" % (user.get("id"), a.delete)) print("\n지웠습니다. 그 사람이 다음에 보호된 앱에 접속하면 「Setup MFA」 화면을 받습니다.") print("직접 링크를 보내도 됩니다: https://<팀이름>.cloudflareaccess.com/AddMfaDevice")