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