feat: enable Cloudron TOTP 2FA for vp-techops (CMMC compliance)

Cloudron's 2FA enrollment flow discovered via comprehensive DOM dump:
1. Profile page -> click "Setup" to start 2FA
2. Cloudron defaults to Passkey -> click "switchToTotp"
3. TOTP secret appears as base32 text -> extract via regex
4. Enter code in #totpTokenInput -> click Enable

Fixed wrong selectors in cloudron_panel_login: the OIDC TOTP field is
#inputTotpToken (not #inputTotp as previously assumed). Verified full
2FA round-trip: password login -> TOTP prompt -> code entry -> #/apps.

2FA is now enabled on the vp-techops Cloudron account with TOTP secret
stored in Bitwarden. This removes a hard blocker for CMMC L3 compliance.
This commit is contained in:
TSYS Group COO
2026-08-13 21:01:22 -05:00
parent f633a10f80
commit be2f607839
6 changed files with 905 additions and 73 deletions
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env python3
"""
enable-cloudron-2fa.py -- Enable TOTP 2FA on the vp-techops Cloudron account.
Flow discovered via DOM dump:
1. Login to Cloudron panel
2. Navigate to #/profile
3. Click "Setup" for 2FA enrollment
4. Click "switchToTotp" link (Cloudron defaults to Passkey)
5. Extract TOTP secret from the TOTP setup form
6. Generate TOTP code, enter it, confirm
7. Verify 2FA is enabled
Usage:
docker compose run --rm --entrypoint python3 provision enable-cloudron-2fa.py
"""
import os
import re
import sys
import time
from pathlib import Path
import pyotp
from playwright.sync_api import sync_playwright
sys.path.insert(0, os.path.dirname(__file__))
from bw_helper import BitwardenHelper
CLOUDRON_BASE = os.environ.get("CLOUDRON_BASE", "https://my.knownelement.com")
STATE_DIR = Path("/app/state")
STATE_DIR.mkdir(parents=True, exist_ok=True)
BW_ITEM = "vp-techops Cloudron"
EMAIL = "tsgstaff-coo-vptechops@turnsys.com"
def dump(page, label):
"""Save screenshot + simplified text dump."""
ts = time.strftime("%H%M%S")
try:
page.screenshot(path=str(STATE_DIR / f"2fa-{label}-{ts}.png"), full_page=True)
except Exception:
pass
text = page.evaluate("() => document.body.innerText")
(STATE_DIR / f"2fa-{label}-{ts}.txt").write_text(f"URL: {page.url}\n\n{text[:3000]}")
print(f" [{label}] URL: {page.url}")
def main():
bw = BitwardenHelper(
client_id=os.environ["BW_CLIENTID"],
client_secret=os.environ["BW_CLIENTSECRET"],
password=os.environ["BW_PASSWORD"],
server_url=os.environ.get("BW_SERVER", ""),
)
bw.login()
password = bw.get_item_password(BW_ITEM)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 1024})
page = context.new_page()
# === Step 1: Login ===
print("=== STEP 1: Login to Cloudron ===")
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=15000)
page.wait_for_timeout(2000)
page.wait_for_selector("#inputPassword", timeout=15000)
page.click("#inputUsername")
page.keyboard.type(EMAIL)
page.click("#inputPassword")
page.keyboard.type(password)
page.locator('[role="button"]:has-text("Log in")').first.click()
page.wait_for_timeout(5000)
print(f" Logged in: {page.url}")
# === Step 2: Navigate to profile ===
print("=== STEP 2: Navigate to #/profile ===")
page.evaluate('() => window.location.hash = "#/profile"')
page.wait_for_timeout(3000)
dump(page, "01-profile")
# === Step 3: Find and click 2FA Setup ===
print("=== STEP 3: Click 2FA Setup ===")
# Look for "Setup" text or enable button near TOTP
setup_clicked = False
for selector in [
'text=Setup',
'[role="button"]:has-text("Setup")',
'button:has-text("Setup")',
'a:has-text("Setup")',
'text=Enable',
]:
loc = page.locator(selector)
if loc.count() > 0 and loc.first.is_visible():
loc.first.click()
page.wait_for_timeout(3000)
setup_clicked = True
print(f" Clicked: {selector}")
break
if not setup_clicked:
print(" ERROR: Could not find Setup button")
dump(page, "ERROR-no-setup")
browser.close()
return
dump(page, "02-after-setup-click")
# === Step 4: Switch from Passkey to TOTP ===
print("=== STEP 4: Switch to TOTP mode ===")
page_text = page.evaluate("() => document.body.innerText")
if "switchToTotp" in page_text or "TOTP" in page_text:
# Click the switchToTotp link
switched = False
for selector in [
'text=switchToTotp',
'text=profile.enable2FA.switchToTotp',
'a:has-text("TOTP")',
'[role="button"]:has-text("TOTP")',
'text=Use TOTP',
'text=totp',
]:
loc = page.locator(selector)
if loc.count() > 0 and loc.first.is_visible():
loc.first.click()
page.wait_for_timeout(3000)
switched = True
print(f" Clicked: {selector}")
break
if not switched:
print(" WARNING: Could not find switchToTotp link, dumping page")
dump(page, "ERROR-no-switch")
else:
print(" Already in TOTP mode (no Passkey option visible)")
dump(page, "03-totp-mode")
# === Step 5: Extract TOTP secret ===
print("=== STEP 5: Extract TOTP secret ===")
page_text = page.evaluate("() => document.body.innerText")
# Look for the secret key in the page text
# TOTP secrets are typically base32: uppercase A-Z, 2-7, = padding
secret = ""
# Method 1: Look for a code/pre element with the secret
code_els = page.query_selector_all("code, pre, .totp-secret, [class*='secret']")
for el in code_els:
text = el.text_content().strip()
if text and re.match(r'^[A-Z2-7=]+$', text.replace(" ", "")):
secret = text.replace(" ", "")
print(f" Found secret in element: {secret[:8]}...")
break
# Method 2: Look in page text for base32 strings
if not secret:
# Cloudron typically shows the secret in groups of 4 chars
matches = re.findall(r'[A-Z2-7]{16,}=?', page_text.replace(" ", ""))
if matches:
secret = matches[0]
print(f" Found secret in text: {secret[:8]}...")
# Method 3: Look for a readonly input
if not secret:
secret_input = page.query_selector('input[readonly], input[type="text"]')
if secret_input:
val = secret_input.get_attribute("value") or ""
if val and re.match(r'^[A-Z2-7=]+$', val.replace(" ", "")):
secret = val.replace(" ", "")
print(f" Found secret in input: {secret[:8]}...")
# Method 4: Try QR code
if not secret:
print(" No text secret found, trying QR code...")
qr_img = page.query_selector('img[src*="data:image"]')
if qr_img:
import base64, io
from PIL import Image
from pyzbar.pyzbar import decode as pyzbar_decode
qr_src = qr_img.get_attribute("src")
header, b64data = qr_src.split(",", 1)
img_bytes = base64.b64decode(b64data)
img = Image.open(io.BytesIO(img_bytes))
decoded = pyzbar_decode(img)
if decoded:
uri = decoded[0].data.decode()
if "secret=" in uri:
secret = uri.split("secret=")[1].split("&")[0]
print(f" Found secret in QR: {secret[:8]}...")
if not secret:
print(" ERROR: Could not extract TOTP secret")
# Dump all inputs and their attributes
inputs = page.evaluate("""() => {
return Array.from(document.querySelectorAll('input, [role="textbox"]')).map(el => ({
tag: el.tagName, type: el.type, id: el.id, name: el.name,
value: (el.value || '').substring(0, 40),
placeholder: el.placeholder || '',
readonly: el.readOnly,
}));
}""")
print(f" All inputs: {inputs}")
dump(page, "ERROR-no-secret")
browser.close()
return
print(f" TOTP Secret: {secret}")
# === Step 6: Enter TOTP confirmation code ===
print("=== STEP 6: Enter TOTP confirmation code ===")
totp_code = pyotp.TOTP(secret).now()
print(f" TOTP Code: {totp_code}")
# Find the TOTP token input
token_input = None
for selector in [
'#totpTokenInput',
'input[name="totpToken"]',
'input[name="token"]',
'input[placeholder*="TOTP" i]',
'input[placeholder*="code" i]',
'input[placeholder*="token" i]',
'input[type="text"]:visible',
'input[type="number"]:visible',
]:
loc = page.locator(selector)
if loc.count() > 0 and loc.first.is_visible():
token_input = loc.first
print(f" Found token input: {selector}")
break
if not token_input:
# Fallback: scan all visible text inputs
inputs = page.query_selector_all('input[type="text"], input[type="number"], input:not([type])')
for inp in inputs:
try:
if inp.is_visible():
token_input = inp
print(f" Found fallback token input")
break
except Exception:
continue
if token_input:
# Use keyboard.type for Vue/Pankow compatibility
token_input.click()
page.keyboard.type(totp_code)
page.wait_for_timeout(500)
dump(page, "04-token-entered")
# Click confirm button
print("=== STEP 7: Confirm 2FA ===")
confirmed = False
for btn_text in ["Confirm", "Enable", "Verify", "OK", "Save", "Done", "Continue"]:
loc = page.locator(f'[role="button"]:has-text("{btn_text}"), button:has-text("{btn_text}")')
if loc.count() > 0 and loc.first.is_visible():
loc.first.click()
page.wait_for_timeout(3000)
confirmed = True
print(f" Clicked confirm: {btn_text}")
break
if not confirmed:
# Try form submit
page.evaluate("() => { const f = document.querySelector('form'); if (f) f.requestSubmit(); }")
page.wait_for_timeout(3000)
print(" Submitted form directly")
dump(page, "05-after-confirm")
# === Step 8: Verify 2FA is enabled ===
print("=== STEP 8: Verify 2FA enabled ===")
page_text = page.evaluate("() => document.body.innerText")
if any(w in page_text.lower() for w in ["enabled", "2fa is enabled", "totp is enabled"]):
print(" 2FA appears ENABLED!")
else:
print(f" 2FA status unclear, checking page text...")
for line in page_text.split("\n"):
low = line.lower().strip()
if any(w in low for w in ["totp", "2fa", "enable", "disable", "verified"]):
print(f" {line.strip()}")
else:
print(" ERROR: Could not find TOTP token input")
dump(page, "ERROR-no-token-input")
# Store the TOTP secret in BW
print(f"\n=== STORING TOTP SECRET IN BITWARDEN ===")
item = bw.get_item(BW_ITEM)
if item:
current_totp = item.get("login", {}).get("totp", "")
if current_totp == secret:
print(" TOTP secret already stored in BW")
else:
bw.update_item(BW_ITEM, totp_secret=secret)
print(f" Updated BW item '{BW_ITEM}' with TOTP secret")
else:
print(f" WARNING: BW item '{BW_ITEM}' not found")
browser.close()
print("\n=== DONE ===")
if __name__ == "__main__":
main()