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
+180 -73
View File
@@ -162,104 +162,124 @@ def enroll_cloudron(
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
#inputTotpSecret/#inputTotpToken selectors.
Cloudron's 2FA enrollment flow (discovered via DOM dump):
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).
"""
name = agent["name"]
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.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:
for nav_text in ["Profile", "Settings", "Account", "Security"]:
sec_link = page.locator(f'a[href*="#/profile"], a[href*="#/security"], a:has-text("{nav_text}")')
for nav_text in ["Profile", "Settings", "Account"]:
sec_link = page.locator(f'a[href*="#/profile"], a:has-text("{nav_text}")')
if sec_link.count() > 0:
sec_link.first.click()
page.wait_for_timeout(3000)
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")
if "totp" in page_text.lower() and "enabled" in page_text.lower():
log.info(f"[{name}] 2FA already enabled on Cloudron")
return ""
# Click the TOTP enable/setup button on the profile page
# Cloudron profile has a TOTP section with an Enable button
totp_clicked = False
# Step 1: Click "Setup" to start 2FA enrollment
setup_clicked = False
for selector in [
'button:has-text("Enable")',
'text=Setup',
'[role="button"]:has-text("Setup")',
'button:has-text("Setup")',
'a:has-text("Enable")',
'input[value="Enable"]',
'button:has-text("TOTP")',
'a:has-text("Setup")',
]:
btn = page.locator(selector)
if btn.count() > 0:
btn.first.click()
loc = page.locator(selector)
if loc.count() > 0 and loc.first.is_visible():
loc.first.click()
page.wait_for_timeout(3000)
totp_clicked = True
setup_clicked = True
break
if not totp_clicked:
log.warning(f"[{name}] Could not find TOTP enable button on profile page")
_debug_dump(page, f"cloudron-2fa-no-btn-{name}")
if not setup_clicked:
log.warning(f"[{name}] Could not find 2FA Setup button")
_debug_dump(page, f"cloudron-2fa-no-setup-{name}")
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
secret_el = page.query_selector('input[readonly], code, .totp-secret, #inputTotpSecret')
_debug_dump(page, f"cloudron-2fa-totp-mode-{name}")
# Step 3: Extract TOTP secret (base32 string in page text)
import re
page_text = page.evaluate("() => document.body.innerText")
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:
qr_img = page.query_selector('img[src*="data:image"]')
if qr_img:
qr_src = qr_img.get_attribute("src")
totp_secret = decode_qr_from_base64(qr_src)
matches = re.findall(r'[A-Z2-7]{16,}=?', page_text.replace(" ", ""))
if matches:
totp_secret = matches[0]
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 ""
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
totp_code = pyotp.TOTP(totp_secret).now()
# Cloudron TOTP confirmation input
code_input = page.query_selector('#totpTokenInput, #inputTotpToken, input[name="totpToken"]')
if not code_input:
inputs = page.query_selector_all('input[type="text"]')
for inp in inputs:
if inp.is_visible():
code_input = inp
token_input = page.query_selector("#totpTokenInput")
if not token_input:
for sel in ['input[name="totpToken"]', 'input[type="text"]:visible']:
loc = page.locator(sel)
if loc.count() > 0 and loc.first.is_visible():
token_input = loc.first.element_handle()
break
if code_input:
code_input.fill(totp_code)
if token_input:
token_input.click()
page.keyboard.type(totp_code)
page.wait_for_timeout(500)
# Click confirm button using Playwright locator
for btn_text in ["Confirm", "Enable", "Verify", "OK"]:
btn = page.locator(f'button:has-text("{btn_text}")')
if btn.count() > 0:
btn.first.click()
# Click Enable/Confirm button
for btn_text in ["Enable", "Confirm", "Verify", "OK", "Save"]:
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()
break
page.wait_for_timeout(3000)
log.info(f"[{name}] 2FA confirmation submitted")
@@ -346,16 +366,21 @@ def cloudron_panel_login(page: Page, agent: dict, bw: BitwardenHelper) -> bool:
submitted = True
page.wait_for_timeout(5000)
# Handle TOTP if prompted
totp_input = page.query_selector("#inputTotp")
# Handle TOTP if prompted (Cloudron OIDC uses #inputTotpToken, not #inputTotp)
totp_input = page.query_selector("#inputTotpToken")
if totp_input and totp_input.is_visible():
totp_code = bw.get_totp(cloudron_item)
totp_input.fill(totp_code)
for text in ["Log in", "Sign in", "Submit", "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
totp_input.click()
page.keyboard.type(totp_code)
# Cloudron TOTP submit button has specific ID
submit = page.locator('#totpTokenSubmitButton')
if submit.count() == 0:
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:
page.evaluate("() => { const f = document.querySelector('form'); if (f) f.requestSubmit(); }")
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)
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 not page.query_selector("#inputPassword"):
log.info(f"[{name}] OIDC auto-consent (already authenticated)")
else:
page.wait_for_timeout(2000)
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")
cloudron_email = agent.get("cloudron_email", f"{name}@turnsys.com")
cloudron_item = f"{name} Cloudron"
password = bw.get_item_password(cloudron_item)
page.wait_for_selector("#inputPassword", timeout=15000)
page.click("#inputUsername")
page.keyboard.type(cloudron_email)
page.click("#inputPassword")
page.keyboard.type(password)
# Submit using Pankow UI pattern: div[role="button"]
for text in ["Log in", "Sign in", "Submit", "Continue"]:
btn = page.locator(f'[role="button"]:has-text("{text}"), button:has-text("{text}")')
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()
break
page.wait_for_timeout(5000)
else:
log.info(f"[{name}] OIDC session active — looking for consent")
# After OIDC login, may need consent
page.wait_for_timeout(3000)
for consent_text in ["Continue", "Authorize", "Allow", "Accept"]:
consent_btn = page.locator(f'[role="button"]:has-text("{consent_text}"), button:has-text("{consent_text}")')
if consent_btn.count() > 0 and consent_btn.first.is_visible():
consent_btn.first.click()
# Try to find and click any consent/authorize button.
# The OIDC page may require explicit consent even when authenticated.
page.wait_for_timeout(2000)
for consent_text in ["Continue", "Authorize", "Allow", "Accept", "Approve"]:
consent_btn = page.locator(f'[role="button"]:has-text("{consent_text}"), button:has-text("{consent_text}"), input[value="{consent_text}"]')
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)
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)
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)
sso_login(page, f"{url}/", agent, bw,
sso_button_selector='button[id*="login-oauth"], a[href*="auth"]')
# Discourse uses a login modal — can't use sso_login (it reloads the page).
# 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
# Note: In Discourse, only admin can create API keys via UI