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:
@@ -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()
|
||||
Reference in New Issue
Block a user