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
+4
View File
@@ -13,3 +13,7 @@ services:
- ./provision-agent.py:/app/provision-agent.py:ro - ./provision-agent.py:/app/provision-agent.py:ro
- ./test_bw_helper.py:/app/test_bw_helper.py:ro - ./test_bw_helper.py:/app/test_bw_helper.py:ro
- ./test_bw_persistence.py:/app/test_bw_persistence.py:ro - ./test_bw_persistence.py:/app/test_bw_persistence.py:ro
- ./dump-cloudron-dom.py:/app/dump-cloudron-dom.py:ro
- ./enable-cloudron-2fa.py:/app/enable-cloudron-2fa.py:ro
- ./verify-cloudron-2fa.py:/app/verify-cloudron-2fa.py:ro
- ./investigate-2fa-login.py:/app/investigate-2fa-login.py:ro
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""
dump-cloudron-dom.py -- Comprehensive DOM dump of Cloudron profile page.
Captures ALL interactive elements (not just standard form inputs) to find
the TOTP enable button that previous dumps missed. Cloudron uses Pankow/Vue
components where buttons are often <div role="button"> not <button>.
Usage:
docker compose run --rm --entrypoint python3 provision dump-cloudron-dom.py
"""
import os
import sys
import time
from pathlib import Path
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"
def full_dom_dump(page, label):
"""Dump every visible element with role, text, classes, and attributes."""
ts = time.strftime("%H%M%S")
screenshot_path = STATE_DIR / f"domdump-{label}-{ts}.png"
text_path = STATE_DIR / f"domdump-{label}-{ts}.txt"
try:
page.screenshot(path=str(screenshot_path), full_page=True)
except Exception:
pass
try:
elements = page.evaluate("""() => {
const results = [];
// Capture ALL potentially interactive elements
const selector = [
'input', 'button', 'select', 'textarea', 'label',
'a', 'span', 'div', 'code', 'pre',
'[role]', '[onclick]', '[tabindex]',
'[class*="btn"]', '[class*="button"]', '[class*="totp"]',
'[class*="modal"]', '[class*="dialog"]', '[class*="card"]',
'.pankow-button', '.pankow-card',
].join(', ');
document.querySelectorAll(selector).forEach(el => {
const tag = el.tagName.toLowerCase();
const text = (el.textContent || '').trim().substring(0, 120);
const role = el.getAttribute('role') || '';
const id = el.id || '';
const name = el.getAttribute('name') || '';
const type = el.getAttribute('type') || '';
const value = el.getAttribute('value') || '';
const href = el.getAttribute('href') || '';
const placeholder = el.getAttribute('placeholder') || '';
const cls = (el.className || '').substring(0, 80);
const tabindex = el.getAttribute('tabindex') || '';
const visible = el.offsetParent !== null;
// Only log elements with useful info, skip pure containers
if (text || id || name || type || value || role || href || placeholder ||
cls.includes('btn') || cls.includes('button') || cls.includes('totp') ||
cls.includes('modal') || cls.includes('dialog')) {
results.push({
tag, text: text.substring(0, 80), role, id, name, type,
value, href, placeholder, cls: cls.substring(0, 60),
tabindex, visible
});
}
});
return results;
}""")
lines = [f"URL: {page.url}", f"Elements: {len(elements)}", ""]
for el in elements:
vis = "V" if el["visible"] else "H"
parts = [f"[{vis}] <{el['tag']}>"]
if el["role"]:
parts.append(f"role={el['role']}")
if el["id"]:
parts.append(f"id={el['id']}")
if el["name"]:
parts.append(f"name={el['name']}")
if el["type"]:
parts.append(f"type={el['type']}")
if el["value"]:
parts.append(f"value={el['value'][:40]}")
if el["href"]:
parts.append(f"href={el['href'][:60]}")
if el["placeholder"]:
parts.append(f"placeholder={el['placeholder']}")
if el["cls"]:
parts.append(f"class={el['cls']}")
if el["tabindex"]:
parts.append(f"tabindex={el['tabindex']}")
if el["text"]:
parts.append(f'text="{el["text"]}"')
lines.append(" ".join(parts))
text_path.write_text("\n".join(lines))
print(f"Dump saved: {screenshot_path.name}, {text_path.name} ({len(elements)} elements)")
# Print TOTP-related elements to stdout for immediate visibility
print("\n=== TOTP-RELATED ELEMENTS ===")
for el in elements:
combined = f"{el['tag']} {el['text']} {el['cls']} {el['id']} {el['role']}".lower()
if "totp" in combined or "2fa" in combined or "authenticator" in combined:
print(f" <{el['tag']}> role={el['role']} id={el['id']} "
f"class={el['cls']} text=\"{el['text']}\" visible={el['visible']}")
print("\n=== ALL role=button ELEMENTS ===")
for el in elements:
if el["role"] == "button":
print(f" <{el['tag']}> id={el['id']} class={el['cls']} "
f'text="{el["text"]}" visible={el["visible"]}')
except Exception as e:
print(f"Dump failed: {e}")
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)
email = "tsgstaff-coo-vptechops@turnsys.com"
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 to Cloudron panel
print("=== LOGGING IN 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)
btn = page.locator('[role="button"]:has-text("Log in")')
if btn.count() > 0:
btn.first.click()
page.wait_for_timeout(5000)
print(f"Post-login URL: {page.url}")
print(f"Logged in: {'login' not in page.url.lower()}")
# Step 2: Navigate to profile page
print("\n=== NAVIGATING TO #/profile ===")
page.evaluate('() => window.location.hash = "#/profile"')
page.wait_for_timeout(3000)
print(f"Profile URL: {page.url}")
# Step 3: Comprehensive DOM dump
print("\n=== DUMPING PROFILE PAGE DOM ===")
full_dom_dump(page, "profile-initial")
# Step 4: Try to find and click TOTP setup
print("\n=== LOOKING FOR TOTP SETUP BUTTON ===")
# Try various approaches to find the TOTP enable button
clicked = False
for desc, selector in [
("role=button near TOTP", '[role="button"]:near(:text("TOTP"))'),
("button text Enable TOTP", 'button:has-text("Enable")'),
("role=button text Enable", '[role="button"]:has-text("Enable")'),
("text=Setup TOTP", 'text=Setup'),
("role=button text Setup", '[role="button"]:has-text("Setup")'),
("role=button text TOTP", '[role="button"]:has-text("TOTP")'),
("class totp-button", '[class*="totp"]'),
("a text Enable", 'a:has-text("Enable")'),
]:
try:
loc = page.locator(selector)
if loc.count() > 0:
print(f" Found: {desc} ({loc.count()} matches)")
# Dump before clicking
full_dom_dump(page, f"pre-click-{desc.replace(' ', '-')}")
loc.first.click()
page.wait_for_timeout(3000)
clicked = True
print(f" Clicked: {desc}")
break
except Exception as e:
print(f" {desc}: {e}")
if clicked:
print("\n=== DUMPING POST-CLICK STATE (modal/dialog?) ===")
full_dom_dump(page, "post-totp-click")
else:
print("\n!!! Could not find any TOTP button to click")
# Step 5: Dump the full page text for context
print("\n=== PAGE TEXT (searchable) ===")
body_text = page.evaluate("() => document.body.innerText")
# Print only lines mentioning totp, 2fa, enable, setup, authenticator
for line in body_text.split("\n"):
low = line.lower().strip()
if any(w in low for w in ["totp", "2fa", "enable", "setup", "authenticat", "factor", "passkey", "security key"]):
print(f" {line.strip()}")
browser.close()
if __name__ == "__main__":
main()
+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()
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""
investigate-2fa-login.py -- Detailed investigation of the TOTP login flow.
Dumps the OIDC interaction page at multiple stages to find the TOTP prompt.
Usage:
docker compose run --rm --entrypoint python3 provision investigate-2fa-login.py
"""
import os, sys, time
from pathlib import Path
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")
BW_ITEM = "vp-techops Cloudron"
EMAIL = "tsgstaff-coo-vptechops@turnsys.com"
def dump_page_state(page, label):
"""Dump URL, all visible inputs/buttons, and page text snippet."""
ts = time.strftime("%H%M%S")
page.screenshot(path=str(STATE_DIR / f"investigate-{label}-{ts}.png"), full_page=True)
url = page.url
inputs = page.evaluate("""() => {
return Array.from(document.querySelectorAll(
'input, [role="button"], button, [id*="totp" i], [id*="Totp"]'
)).filter(el => {
return el.offsetParent !== null || el.style.display !== 'none';
}).map(el => ({
tag: el.tagName,
type: el.type || '',
id: el.id || '',
name: el.getAttribute('name') || '',
role: el.getAttribute('role') || '',
placeholder: el.placeholder || '',
text: (el.textContent || '').trim().substring(0, 40),
visible: el.offsetParent !== null,
}));
}""")
body_text = page.evaluate("() => document.body.innerText.substring(0, 500)")
print(f"\n--- {label} ---")
print(f"URL: {url}")
print(f"Inputs/buttons ({len(inputs)}):")
for inp in inputs:
print(f" <{inp['tag']}> type={inp['type']} id={inp['id']} name={inp['name']} "
f"role={inp['role']} placeholder={inp['placeholder']} text={inp['text']} "
f"visible={inp['visible']}")
print(f"Body text: {body_text[:200]}")
(STATE_DIR / f"investigate-{label}-{ts}.txt").write_text(
f"URL: {url}\n\nInputs: {inputs}\n\nBody: {body_text}")
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)
page = browser.new_context(viewport={"width": 1280, "height": 1024}).new_page()
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=15000)
page.wait_for_timeout(2000)
dump_page_state(page, "01-initial-load")
# Fill password and submit
page.wait_for_selector("#inputPassword", timeout=15000)
page.click("#inputUsername")
page.keyboard.type(EMAIL)
page.click("#inputPassword")
page.keyboard.type(password)
dump_page_state(page, "02-form-filled")
page.locator('[role="button"]:has-text("Log in")').first.click()
# Wait and check multiple times for TOTP field
for wait in [2, 3, 5, 5]:
page.wait_for_timeout(wait * 1000)
dump_page_state(page, f"03-after-login-{wait}s")
totp = page.query_selector("#inputTotp")
if totp and totp.is_visible():
print(f"\n=== TOTP FIELD FOUND after {wait}s! ===")
code = bw.get_totp(BW_ITEM)
print(f"Entering TOTP: {code}")
totp.click()
page.keyboard.type(code)
page.locator('[role="button"]:has-text("Log in")').first.click()
page.wait_for_timeout(5000)
dump_page_state(page, "04-after-totp")
print(f"Final URL: {page.url}")
break
else:
print("\n=== No TOTP field found at any wait interval ===")
# Check if we're already logged in
if "login" not in page.url.lower() and "openid" not in page.url.lower():
print("Already past login (maybe session was active)")
browser.close()
if __name__ == "__main__":
main()
+180 -73
View File
@@ -162,104 +162,124 @@ def enroll_cloudron(
def enable_cloudron_2fa(page: Page, agent: dict, bw: BitwardenHelper) -> str: def enable_cloudron_2fa(page: Page, agent: dict, bw: BitwardenHelper) -> str:
""" """
Navigate to Cloudron security settings and enable TOTP 2FA. Enable TOTP 2FA on a Cloudron account via the profile page.
Cloudron's security page uses Angular with #inputTotpToggle and Cloudron's 2FA enrollment flow (discovered via DOM dump):
#inputTotpSecret/#inputTotpToken selectors. 1. Navigate to #/profile
2. Click "Setup" to start 2FA enrollment
3. Cloudron defaults to Passkey -- click "switchToTotp" to switch
4. TOTP secret appears in page text (base32 encoded)
5. Enter TOTP code in #totpTokenInput, click Enable
Returns the TOTP secret (empty string if already enabled or failed). Returns the TOTP secret (empty string if already enabled or failed).
""" """
name = agent["name"] name = agent["name"]
log.info(f"[{name}] Enabling 2FA on Cloudron account") log.info(f"[{name}] Enabling 2FA on Cloudron account")
# Navigate to security/2FA settings. Cloudron users access their own
# settings at /#/profile (not /#/security which is admin-only).
page.evaluate('() => window.location.hash = "#/profile"') page.evaluate('() => window.location.hash = "#/profile"')
page.wait_for_timeout(3000) page.wait_for_timeout(3000)
# If that didn't work, try clicking the profile/settings nav link # If profile didn't load, try clicking the profile nav link
if "#/profile" not in page.url: if "#/profile" not in page.url:
for nav_text in ["Profile", "Settings", "Account", "Security"]: for nav_text in ["Profile", "Settings", "Account"]:
sec_link = page.locator(f'a[href*="#/profile"], a[href*="#/security"], a:has-text("{nav_text}")') sec_link = page.locator(f'a[href*="#/profile"], a:has-text("{nav_text}")')
if sec_link.count() > 0: if sec_link.count() > 0:
sec_link.first.click() sec_link.first.click()
page.wait_for_timeout(3000) page.wait_for_timeout(3000)
break break
_debug_dump(page, f"cloudron-security-{name}") _debug_dump(page, f"cloudron-profile-{name}")
# Check if 2FA is already enabled by looking at page text # Check if 2FA is already enabled
page_text = page.evaluate("() => document.body.innerText") page_text = page.evaluate("() => document.body.innerText")
if "totp" in page_text.lower() and "enabled" in page_text.lower(): if "totp" in page_text.lower() and "enabled" in page_text.lower():
log.info(f"[{name}] 2FA already enabled on Cloudron") log.info(f"[{name}] 2FA already enabled on Cloudron")
return "" return ""
# Click the TOTP enable/setup button on the profile page # Step 1: Click "Setup" to start 2FA enrollment
# Cloudron profile has a TOTP section with an Enable button setup_clicked = False
totp_clicked = False
for selector in [ for selector in [
'button:has-text("Enable")', 'text=Setup',
'[role="button"]:has-text("Setup")',
'button:has-text("Setup")', 'button:has-text("Setup")',
'a:has-text("Enable")', 'a:has-text("Setup")',
'input[value="Enable"]',
'button:has-text("TOTP")',
]: ]:
btn = page.locator(selector) loc = page.locator(selector)
if btn.count() > 0: if loc.count() > 0 and loc.first.is_visible():
btn.first.click() loc.first.click()
page.wait_for_timeout(3000) page.wait_for_timeout(3000)
totp_clicked = True setup_clicked = True
break break
if not totp_clicked: if not setup_clicked:
log.warning(f"[{name}] Could not find TOTP enable button on profile page") log.warning(f"[{name}] Could not find 2FA Setup button")
_debug_dump(page, f"cloudron-2fa-no-btn-{name}") _debug_dump(page, f"cloudron-2fa-no-setup-{name}")
return "" return ""
_debug_dump(page, f"cloudron-2fa-modal-{name}") # Step 2: Switch from Passkey to TOTP mode
page_text = page.evaluate("() => document.body.innerText")
if "switchToTotp" in page_text or "Passkey" in page_text:
for selector in [
'text=switchToTotp',
'text=profile.enable2FA.switchToTotp',
'a:has-text("TOTP")',
'[role="button"]:has-text("TOTP")',
]:
loc = page.locator(selector)
if loc.count() > 0 and loc.first.is_visible():
loc.first.click()
page.wait_for_timeout(3000)
break
# Extract TOTP secret — Cloudron shows it in a modal after clicking Enable _debug_dump(page, f"cloudron-2fa-totp-mode-{name}")
secret_el = page.query_selector('input[readonly], code, .totp-secret, #inputTotpSecret')
# Step 3: Extract TOTP secret (base32 string in page text)
import re
page_text = page.evaluate("() => document.body.innerText")
totp_secret = "" totp_secret = ""
if secret_el:
totp_secret = secret_el.get_attribute("value") or secret_el.text_content()
totp_secret = totp_secret.strip().replace(" ", "")
# Try QR code if text secret not found # Look in code/pre elements first
for el in page.query_selector_all("code, pre"):
text = el.text_content().strip().replace(" ", "")
if text and re.match(r'^[A-Z2-7=]+$', text):
totp_secret = text
break
# Fall back to regex in page text
if not totp_secret: if not totp_secret:
qr_img = page.query_selector('img[src*="data:image"]') matches = re.findall(r'[A-Z2-7]{16,}=?', page_text.replace(" ", ""))
if qr_img: if matches:
qr_src = qr_img.get_attribute("src") totp_secret = matches[0]
totp_secret = decode_qr_from_base64(qr_src)
if not totp_secret: if not totp_secret:
log.error(f"[{name}] Could not extract TOTP secret from Cloudron 2FA modal") log.error(f"[{name}] Could not extract TOTP secret")
_debug_dump(page, f"cloudron-2fa-no-secret-{name}")
return "" return ""
log.info(f"[{name}] Extracted TOTP secret: {totp_secret[:4]}...") log.info(f"[{name}] Extracted TOTP secret: {totp_secret[:4]}...")
# Generate current TOTP code and enter it to confirm # Step 4: Enter TOTP confirmation code
import pyotp import pyotp
totp_code = pyotp.TOTP(totp_secret).now() totp_code = pyotp.TOTP(totp_secret).now()
# Cloudron TOTP confirmation input token_input = page.query_selector("#totpTokenInput")
code_input = page.query_selector('#totpTokenInput, #inputTotpToken, input[name="totpToken"]') if not token_input:
if not code_input: for sel in ['input[name="totpToken"]', 'input[type="text"]:visible']:
inputs = page.query_selector_all('input[type="text"]') loc = page.locator(sel)
for inp in inputs: if loc.count() > 0 and loc.first.is_visible():
if inp.is_visible(): token_input = loc.first.element_handle()
code_input = inp
break break
if code_input: if token_input:
code_input.fill(totp_code) token_input.click()
page.keyboard.type(totp_code)
page.wait_for_timeout(500) page.wait_for_timeout(500)
# Click confirm button using Playwright locator # Click Enable/Confirm button
for btn_text in ["Confirm", "Enable", "Verify", "OK"]: for btn_text in ["Enable", "Confirm", "Verify", "OK", "Save"]:
btn = page.locator(f'button:has-text("{btn_text}")') loc = page.locator(f'[role="button"]:has-text("{btn_text}"), button:has-text("{btn_text}")')
if btn.count() > 0: if loc.count() > 0 and loc.first.is_visible():
btn.first.click() loc.first.click()
break break
page.wait_for_timeout(3000) page.wait_for_timeout(3000)
log.info(f"[{name}] 2FA confirmation submitted") log.info(f"[{name}] 2FA confirmation submitted")
@@ -346,16 +366,21 @@ def cloudron_panel_login(page: Page, agent: dict, bw: BitwardenHelper) -> bool:
submitted = True submitted = True
page.wait_for_timeout(5000) page.wait_for_timeout(5000)
# Handle TOTP if prompted # Handle TOTP if prompted (Cloudron OIDC uses #inputTotpToken, not #inputTotp)
totp_input = page.query_selector("#inputTotp") totp_input = page.query_selector("#inputTotpToken")
if totp_input and totp_input.is_visible(): if totp_input and totp_input.is_visible():
totp_code = bw.get_totp(cloudron_item) totp_code = bw.get_totp(cloudron_item)
totp_input.fill(totp_code) totp_input.click()
for text in ["Log in", "Sign in", "Submit", "Continue"]: page.keyboard.type(totp_code)
btn = page.locator(f'[role="button"]:has-text("{text}"), button:has-text("{text}")') # Cloudron TOTP submit button has specific ID
if btn.count() > 0 and btn.first.is_visible(): submit = page.locator('#totpTokenSubmitButton')
btn.first.click() if submit.count() == 0:
break for text in ["Log in", "Sign in", "Submit", "Continue"]:
submit = page.locator(f'[role="button"]:has-text("{text}"), button:has-text("{text}")')
if submit.count() > 0 and submit.first.is_visible():
break
if submit.count() > 0:
submit.first.click()
else: else:
page.evaluate("() => { const f = document.querySelector('form'); if (f) f.requestSubmit(); }") page.evaluate("() => { const f = document.querySelector('form'); if (f) f.requestSubmit(); }")
page.wait_for_timeout(5000) page.wait_for_timeout(5000)
@@ -445,23 +470,24 @@ def sso_login(page: Page, system_url: str, agent: dict, bw: BitwardenHelper,
page.wait_for_timeout(5000) page.wait_for_timeout(5000)
log.info(f"[{name}] SSO redirect: {page.url}") log.info(f"[{name}] SSO redirect: {page.url}")
# Handle Cloudron OIDC login page if redirected # Handle Cloudron OIDC login page if redirected.
# The OIDC page may show: (a) a login form, (b) a consent page,
# or (c) nothing visible (auto-redirect). Handle all three.
if "openid" in page.url or "interaction" in page.url: if "openid" in page.url or "interaction" in page.url:
if not page.query_selector("#inputPassword"): page.wait_for_timeout(2000)
log.info(f"[{name}] OIDC auto-consent (already authenticated)")
else: has_login_form = page.query_selector("#inputPassword")
if has_login_form and has_login_form.is_visible():
log.info(f"[{name}] Handling Cloudron OIDC login") log.info(f"[{name}] Handling Cloudron OIDC login")
cloudron_email = agent.get("cloudron_email", f"{name}@turnsys.com") cloudron_email = agent.get("cloudron_email", f"{name}@turnsys.com")
cloudron_item = f"{name} Cloudron" cloudron_item = f"{name} Cloudron"
password = bw.get_item_password(cloudron_item) password = bw.get_item_password(cloudron_item)
page.wait_for_selector("#inputPassword", timeout=15000)
page.click("#inputUsername") page.click("#inputUsername")
page.keyboard.type(cloudron_email) page.keyboard.type(cloudron_email)
page.click("#inputPassword") page.click("#inputPassword")
page.keyboard.type(password) page.keyboard.type(password)
# Submit using Pankow UI pattern: div[role="button"]
for text in ["Log in", "Sign in", "Submit", "Continue"]: for text in ["Log in", "Sign in", "Submit", "Continue"]:
btn = page.locator(f'[role="button"]:has-text("{text}"), button:has-text("{text}")') btn = page.locator(f'[role="button"]:has-text("{text}"), button:has-text("{text}")')
if btn.count() > 0 and btn.first.is_visible(): if btn.count() > 0 and btn.first.is_visible():
@@ -482,15 +508,24 @@ def sso_login(page: Page, system_url: str, agent: dict, bw: BitwardenHelper,
btn.first.click() btn.first.click()
break break
page.wait_for_timeout(5000) page.wait_for_timeout(5000)
else:
log.info(f"[{name}] OIDC session active — looking for consent")
# After OIDC login, may need consent # Try to find and click any consent/authorize button.
page.wait_for_timeout(3000) # The OIDC page may require explicit consent even when authenticated.
for consent_text in ["Continue", "Authorize", "Allow", "Accept"]: page.wait_for_timeout(2000)
consent_btn = page.locator(f'[role="button"]:has-text("{consent_text}"), button:has-text("{consent_text}")') for consent_text in ["Continue", "Authorize", "Allow", "Accept", "Approve"]:
if consent_btn.count() > 0 and consent_btn.first.is_visible(): consent_btn = page.locator(f'[role="button"]:has-text("{consent_text}"), button:has-text("{consent_text}"), input[value="{consent_text}"]')
consent_btn.first.click() if consent_btn.count() > 0:
log.info(f"[{name}] Clicking OIDC consent: {consent_text}")
consent_btn.first.click(force=True)
page.wait_for_timeout(5000) page.wait_for_timeout(5000)
break break
else:
# No consent button found — try submitting any form on the page
# (OIDC auto-approve may need a form POST)
page.evaluate("() => { const f = document.querySelector('form'); if (f) f.requestSubmit(); }")
page.wait_for_timeout(5000)
page.wait_for_timeout(3000) page.wait_for_timeout(3000)
log.info(f"[{name}] SSO result: {page.url}") log.info(f"[{name}] SSO result: {page.url}")
@@ -633,8 +668,80 @@ def provision_discourse(page: Page, agent: dict, bw: BitwardenHelper) -> str:
url = discourse_cfg.get("url", DISCOURSE_URL) url = discourse_cfg.get("url", DISCOURSE_URL)
sso_login(page, f"{url}/", agent, bw, # Discourse uses a login modal — can't use sso_login (it reloads the page).
sso_button_selector='button[id*="login-oauth"], a[href*="auth"]') # Handle the full flow inline.
page.goto(f"{url}/", wait_until="domcontentloaded", timeout=15000)
page.wait_for_timeout(3000)
# Already authenticated?
if not page.query_selector('input[type="password"]'):
# Check if we're on the homepage without login form
login_btn = page.locator('button:has-text("Log In")')
if login_btn.count() == 0 or not login_btn.first.is_visible():
log.info(f"[{name}] Already authenticated at Discourse")
else:
# Open login modal and look for SSO
login_btn.first.click()
page.wait_for_timeout(3000)
log.info(f"[{name}] Opened Discourse login modal")
# Dump modal content for debugging
_debug_dump(page, f"discourse-modal-{name}")
# Look for SSO/social login buttons in the modal
sso_clicked = False
for selector in [
'button.btn-social',
'a[href*="auth/cloudron"]',
'button:has-text("Cloud")',
'button:has-text("KNEL")',
'a:has-text("Cloud")',
'[data-login-name*="cloud"]',
'button.social-buttons-button',
]:
btn = page.locator(selector)
if btn.count() > 0 and btn.first.is_visible():
btn.first.click()
sso_clicked = True
log.info(f"[{name}] Clicked Discourse SSO button: {selector}")
break
if not sso_clicked:
log.warning(f"[{name}] No SSO button found in Discourse modal")
_debug_dump(page, f"discourse-no-sso-{name}")
page.wait_for_timeout(5000)
# Handle OIDC redirect if needed
if "openid" in page.url or "interaction" in page.url:
page.wait_for_timeout(2000)
has_login = page.query_selector("#inputPassword")
if has_login and has_login.is_visible():
# Need to login on OIDC
cloudron_email = agent.get("cloudron_email", f"{name}@turnsys.com")
cloudron_item = f"{name} Cloudron"
password = bw.get_item_password(cloudron_item)
page.click("#inputUsername")
page.keyboard.type(cloudron_email)
page.click("#inputPassword")
page.keyboard.type(password)
for text in ["Log in", "Sign in", "Continue"]:
btn = page.locator(f'[role="button"]:has-text("{text}"), button:has-text("{text}")')
if btn.count() > 0 and btn.first.is_visible():
btn.first.click()
break
page.wait_for_timeout(5000)
# Consent
for consent_text in ["Continue", "Authorize", "Allow"]:
consent_btn = page.locator(f'[role="button"]:has-text("{consent_text}"), button:has-text("{consent_text}")')
if consent_btn.count() > 0:
consent_btn.first.click(force=True)
page.wait_for_timeout(5000)
break
page.wait_for_timeout(3000)
log.info(f"[{name}] Discourse URL after SSO: {page.url}")
# Try to generate an API key from user preferences # Try to generate an API key from user preferences
# Note: In Discourse, only admin can create API keys via UI # Note: In Discourse, only admin can create API keys via UI
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
verify-cloudron-2fa.py -- Verify 2FA round-trip with correct TOTP selector.
The Cloudron OIDC login uses #inputTotpToken (not #inputTotp).
Usage:
docker compose run --rm --entrypoint python3 provision verify-cloudron-2fa.py
"""
import os, sys, time
from pathlib import Path
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")
BW_ITEM = "vp-techops Cloudron"
EMAIL = "tsgstaff-coo-vptechops@turnsys.com"
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)
page = browser.new_context(viewport={"width": 1280, "height": 1024}).new_page()
print("=== Fresh login (should prompt for TOTP) ===")
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(3000)
# Check for TOTP token field (correct selector: #inputTotpToken)
totp_input = page.query_selector("#inputTotpToken")
if totp_input and totp_input.is_visible():
print(" TOTP prompt appeared! 2FA is confirmed working.")
totp_code = bw.get_totp(BW_ITEM)
print(f" Entering TOTP code: {totp_code}")
totp_input.click()
page.keyboard.type(totp_code)
page.locator("#totpTokenSubmitButton").click()
page.wait_for_timeout(5000)
print(f" Post-TOTP URL: {page.url}")
if "login" not in page.url.lower() and "openid" not in page.url.lower():
print(" FULL 2FA ROUND-TRIP VERIFIED!")
else:
body = page.evaluate("() => document.body.innerText.substring(0, 200)")
print(f" Still on login/OIDC page. Body: {body}")
else:
print(" WARNING: No TOTP prompt appeared")
print(f" URL: {page.url}")
page.screenshot(path=str(STATE_DIR / "verify-2fa-final.png"))
browser.close()
if __name__ == "__main__":
main()