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:
@@ -13,3 +13,7 @@ services:
|
||||
- ./provision-agent.py:/app/provision-agent.py:ro
|
||||
- ./test_bw_helper.py:/app/test_bw_helper.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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
+178
-71
@@ -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)
|
||||
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"]:
|
||||
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()
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user