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:
+180
-73
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user