Created
March 29, 2026 13:03
-
-
Save egre55/8e02a43f49e52f1ada5d1bb42bd174f9 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| Google Workspace MFA Reporting (Internal) | |
| --- INSTALL --- | |
| pip install google-api-python-client google-auth-oauthlib --break-system-packages | |
| --- USAGE --- | |
| python3 gws_mfa_report.py --credentials client_secret.json | |
| python3 gws_mfa_report.py --credentials client_secret.json --csv mfa_report.csv | |
| python3 gws_mfa_report.py --credentials client_secret.json --domain <domain> | |
| First run opens a browser for Google sign-in. Token is cached in token.json | |
| for subsequent runs. If you have an existing stale token.json you can delete it. | |
| """ | |
| import argparse | |
| import csv | |
| import json | |
| import os | |
| import sys | |
| from datetime import datetime | |
| from google.auth.transport.requests import Request | |
| from google.oauth2.credentials import Credentials | |
| from google_auth_oauthlib.flow import InstalledAppFlow | |
| from googleapiclient.discovery import build | |
| SCOPES = ["https://www.googleapis.com/auth/admin.directory.user.readonly"] | |
| TOKEN_FILE = "token.json" | |
| def authenticate(credentials_file: str) -> Credentials: | |
| creds = None | |
| if os.path.exists(TOKEN_FILE): | |
| creds = Credentials.from_authorized_user_file(TOKEN_FILE, SCOPES) | |
| if not creds or not creds.valid: | |
| if creds and creds.expired and creds.refresh_token: | |
| creds.refresh(Request()) | |
| else: | |
| flow = InstalledAppFlow.from_client_secrets_file(credentials_file, SCOPES) | |
| creds = flow.run_local_server(port=0) | |
| with open(TOKEN_FILE, "w") as f: | |
| f.write(creds.to_json()) | |
| return creds | |
| def fetch_users(service, domain: str = None) -> list: | |
| users = [] | |
| params = { | |
| "customer": "my_customer", | |
| "maxResults": 500, | |
| "orderBy": "email", | |
| "projection": "full", | |
| } | |
| if domain: | |
| params["domain"] = domain | |
| request = service.users().list(**params) | |
| while request is not None: | |
| response = request.execute() | |
| users.extend(response.get("users", [])) | |
| request = service.users().list_next(request, response) | |
| return users | |
| def print_report(users: list): | |
| active = [u for u in users if not u.get("suspended", False)] | |
| suspended = [u for u in users if u.get("suspended", False)] | |
| enrolled = [u for u in active if u.get("isEnrolledIn2Sv", False)] | |
| enforced = [u for u in active if u.get("isEnforcedIn2Sv", False)] | |
| not_enrolled = [u for u in active if not u.get("isEnrolledIn2Sv", False)] | |
| def pct(n, total): | |
| return f"{(n / total * 100):.0f}%" if total else "0%" | |
| print() | |
| print("=" * 65) | |
| print(" MFA STATUS REPORT") | |
| print(f" {datetime.now().strftime('%Y-%m-%d %H:%M')}") | |
| print("=" * 65) | |
| print() | |
| print(f" Total users: {len(users)}") | |
| print(f" Active: {len(active)}") | |
| print(f" Suspended: {len(suspended)}") | |
| print() | |
| print(f" MFA enrolled: {len(enrolled)}/{len(active)} ({pct(len(enrolled), len(active))})") | |
| print(f" MFA enforced: {len(enforced)}/{len(active)} ({pct(len(enforced), len(active))})") | |
| print(f" MFA not enrolled: {len(not_enrolled)}/{len(active)} ({pct(len(not_enrolled), len(active))})") | |
| if not_enrolled: | |
| print() | |
| print("-" * 65) | |
| print(" ACTIVE USERS WITHOUT MFA") | |
| print("-" * 65) | |
| for u in sorted(not_enrolled, key=lambda x: x.get("primaryEmail", "")): | |
| email = u.get("primaryEmail", "") | |
| last = u.get("lastLoginTime", "Never") | |
| if last != "Never": | |
| last = last[:10] | |
| print(f" {email:<45} last login: {last}") | |
| if enrolled: | |
| print() | |
| print("-" * 65) | |
| print(" ACTIVE USERS WITH MFA") | |
| print("-" * 65) | |
| for u in sorted(enrolled, key=lambda x: x.get("primaryEmail", "")): | |
| email = u.get("primaryEmail", "") | |
| enforced_str = "enforced" if u.get("isEnforcedIn2Sv", False) else "not enforced" | |
| print(f" {email:<45} {enforced_str}") | |
| print() | |
| def export_csv(users: list, filename: str): | |
| with open(filename, "w", newline="") as f: | |
| writer = csv.writer(f) | |
| writer.writerow([ | |
| "Email", "Name", "OU", "Suspended", | |
| "MFA Enrolled", "MFA Enforced", "Last Login" | |
| ]) | |
| for u in sorted(users, key=lambda x: x.get("primaryEmail", "")): | |
| writer.writerow([ | |
| u.get("primaryEmail", ""), | |
| u.get("name", {}).get("fullName", ""), | |
| u.get("orgUnitPath", "/"), | |
| u.get("suspended", False), | |
| u.get("isEnrolledIn2Sv", False), | |
| u.get("isEnforcedIn2Sv", False), | |
| u.get("lastLoginTime", "Never"), | |
| ]) | |
| print(f" Exported to {filename}") | |
| print() | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Google Workspace MFA Status Report") | |
| parser.add_argument( | |
| "--credentials", required=True, | |
| help="Path to OAuth client secret JSON (download from GCP Console)" | |
| ) | |
| parser.add_argument("--domain", default=None, help="Filter by domain") | |
| parser.add_argument("--csv", default=None, help="Export to CSV") | |
| args = parser.parse_args() | |
| if not os.path.exists(args.credentials): | |
| print(f"Error: {args.credentials} not found") | |
| sys.exit(1) | |
| creds = authenticate(args.credentials) | |
| service = build("admin", "directory_v1", credentials=creds) | |
| print("Fetching users...") | |
| users = fetch_users(service, domain=args.domain) | |
| if not users: | |
| print("No users found. Check that you're signing in as a Workspace admin.") | |
| sys.exit(1) | |
| print_report(users) | |
| if args.csv: | |
| export_csv(users, args.csv) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment