feat: Discourse SSO + User API key extraction working
Discourse SSO flow: Cloudron login -> click "Log In" -> click OpenID Connect button -> complete signup (enter username) -> logged in. User API key generated via Discourse RSA-based flow: 1. Generate RSA keypair, submit public key 2. Authorize request on Discourse 3. Capture encrypted payload from POST response 4. Decrypt with PKCS1v15 padding (Discourse uses this, not OAEP) 5. Parse JSON to extract the key field API key verified working: User-Api-Key header returns 30 topics from /latest.json. Key stored in Bitwarden as "vp-techops Discourse". Redmine SSO is blocked: Cloudron returns "You do not have access" -- the vp-techops user needs app access granted by Cloudron admin. Also added cryptography==44.0.1 to requirements for RSA operations.
This commit is contained in:
@@ -17,3 +17,6 @@ services:
|
|||||||
- ./enable-cloudron-2fa.py:/app/enable-cloudron-2fa.py:ro
|
- ./enable-cloudron-2fa.py:/app/enable-cloudron-2fa.py:ro
|
||||||
- ./verify-cloudron-2fa.py:/app/verify-cloudron-2fa.py:ro
|
- ./verify-cloudron-2fa.py:/app/verify-cloudron-2fa.py:ro
|
||||||
- ./investigate-2fa-login.py:/app/investigate-2fa-login.py:ro
|
- ./investigate-2fa-login.py:/app/investigate-2fa-login.py:ro
|
||||||
|
- ./dump-sso-flows.py:/app/dump-sso-flows.py:ro
|
||||||
|
- ./provision-discourse.py:/app/provision-discourse.py:ro
|
||||||
|
- ./provision-discourse-apikey.py:/app/provision-discourse-apikey.py:ro
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
dump-sso-flows.py -- Comprehensive DOM dump and SSO attempt for Redmine + Discourse.
|
||||||
|
|
||||||
|
Establishes Cloudron OIDC session first, then navigates to each app's
|
||||||
|
login page, dumps the DOM, and attempts the SSO flow.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker compose run --rm --entrypoint python3 provision dump-sso-flows.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, sys, time, re
|
||||||
|
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")
|
||||||
|
GITEA_URL = os.environ.get("GITEA_URL", "https://git.knownelement.com")
|
||||||
|
REDMINE_URL = os.environ.get("REDMINE_URL", "https://projects.knownelement.com")
|
||||||
|
DISCOURSE_URL = os.environ.get("DISCOURSE_URL", "https://community.turnsys.com")
|
||||||
|
STATE_DIR = Path("/app/state")
|
||||||
|
BW_ITEM = "vp-techops Cloudron"
|
||||||
|
EMAIL = "tsgstaff-coo-vptechops@turnsys.com"
|
||||||
|
|
||||||
|
|
||||||
|
def dump(page, label):
|
||||||
|
"""Save screenshot + comprehensive element dump."""
|
||||||
|
ts = time.strftime("%H%M%S")
|
||||||
|
try:
|
||||||
|
page.screenshot(path=str(STATE_DIR / f"sso-{label}-{ts}.png"), full_page=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
elements = page.evaluate("""() => {
|
||||||
|
const results = [];
|
||||||
|
const selector = 'input, button, select, textarea, label, a, [role="button"], ' +
|
||||||
|
'h1, h2, h3, h4, code, pre, form, [class*="oauth"], [class*="login"], ' +
|
||||||
|
'[class*="btn"], [id*="login"], [id*="oauth"], [id*="sso"]';
|
||||||
|
document.querySelectorAll(selector).forEach(el => {
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
const text = (el.textContent || '').trim().substring(0, 80);
|
||||||
|
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') || '').substring(0, 50);
|
||||||
|
const cls = (el.getAttribute('class') || '').substring(0, 60);
|
||||||
|
const action = el.getAttribute('action') || '';
|
||||||
|
const visible = el.offsetParent !== null;
|
||||||
|
if (text || id || name || type || value || href || role || action ||
|
||||||
|
cls.includes('btn') || cls.includes('oauth') || cls.includes('login')) {
|
||||||
|
let parts = '<' + tag + '> ';
|
||||||
|
if (role) parts += 'role=' + role + ' ';
|
||||||
|
if (id) parts += 'id=' + id + ' ';
|
||||||
|
if (type) parts += 'type=' + type + ' ';
|
||||||
|
if (name) parts += 'name=' + name + ' ';
|
||||||
|
if (href) parts += 'href=' + href + ' ';
|
||||||
|
if (cls) parts += 'class=' + cls + ' ';
|
||||||
|
parts += 'vis=' + visible + ' text="' + text + '"';
|
||||||
|
results.push(parts);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return results;
|
||||||
|
}""")
|
||||||
|
|
||||||
|
body = page.evaluate("() => document.body.innerText.substring(0, 300)")
|
||||||
|
text_path = STATE_DIR / f"sso-{label}-{ts}.txt"
|
||||||
|
text_path.write_text(f"URL: {page.url}\n\nBody: {body}\n\nElements:\n" + "\n".join(elements))
|
||||||
|
print(f" [{label}] {len(elements)} elements -> {text_path.name}")
|
||||||
|
|
||||||
|
|
||||||
|
def cloudron_login(page, bw):
|
||||||
|
"""Login to Cloudron panel with TOTP support."""
|
||||||
|
password = bw.get_item_password(BW_ITEM)
|
||||||
|
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=15000)
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Already logged in?
|
||||||
|
if "login" not in page.url.lower() and "openid" not in page.url.lower():
|
||||||
|
print(f" Cloudron session already active ({page.url})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Handle TOTP
|
||||||
|
totp = page.query_selector("#inputTotpToken")
|
||||||
|
if totp and totp.is_visible():
|
||||||
|
code = bw.get_totp(BW_ITEM)
|
||||||
|
totp.click()
|
||||||
|
page.keyboard.type(code)
|
||||||
|
page.locator("#totpTokenSubmitButton").click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
|
||||||
|
# Handle consent page
|
||||||
|
for consent in ["Continue", "Authorize", "Allow", "Accept"]:
|
||||||
|
loc = page.locator(f'[role="button"]:has-text("{consent}"), button:has-text("{consent}")')
|
||||||
|
if loc.count() > 0 and loc.first.is_visible():
|
||||||
|
loc.first.click()
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
break
|
||||||
|
|
||||||
|
logged_in = "login" not in page.url.lower() and "openid" not in page.url.lower()
|
||||||
|
print(f" Cloudron login: {'SUCCESS' if logged_in else 'CHECKING...'} ({page.url})")
|
||||||
|
return logged_in
|
||||||
|
|
||||||
|
|
||||||
|
def try_redmine_sso(page, bw):
|
||||||
|
"""Attempt Redmine SSO login."""
|
||||||
|
print("\n=== REDMINE SSO ===")
|
||||||
|
page.goto(f"{REDMINE_URL}/login", wait_until="domcontentloaded", timeout=30000)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
dump(page, "redmine-login-initial")
|
||||||
|
|
||||||
|
# Look for SSO/OAuth button
|
||||||
|
print(" Looking for SSO button...")
|
||||||
|
sso_selectors = [
|
||||||
|
'#login-oauth-submit-1',
|
||||||
|
'button:has-text("KNEL")',
|
||||||
|
'button:has-text("Cloud")',
|
||||||
|
'button:has-text("Continue")',
|
||||||
|
'a:has-text("KNEL")',
|
||||||
|
'a:has-text("Cloud")',
|
||||||
|
'[class*="oauth"]',
|
||||||
|
'input[name="oauth2"]',
|
||||||
|
]
|
||||||
|
|
||||||
|
for sel in sso_selectors:
|
||||||
|
loc = page.locator(sel)
|
||||||
|
if loc.count() > 0 and loc.first.is_visible():
|
||||||
|
print(f" Found SSO button: {sel}")
|
||||||
|
loc.first.click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
dump(page, "redmine-after-sso-click")
|
||||||
|
|
||||||
|
# Check if we hit OIDC consent page
|
||||||
|
if "openid" in page.url.lower():
|
||||||
|
print(f" OIDC page: {page.url}")
|
||||||
|
for consent in ["Continue", "Authorize", "Allow", "Accept"]:
|
||||||
|
cbtn = page.locator(f'[role="button"]:has-text("{consent}"), button:has-text("{consent}")')
|
||||||
|
if cbtn.count() > 0 and cbtn.first.is_visible():
|
||||||
|
cbtn.first.click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
break
|
||||||
|
dump(page, "redmine-after-consent")
|
||||||
|
|
||||||
|
# Check if logged in
|
||||||
|
current_url = page.url
|
||||||
|
body = page.evaluate("() => document.body.innerText.substring(0, 200)")
|
||||||
|
if "/login" not in current_url:
|
||||||
|
print(f" Redmine SSO result: LOGGED IN ({current_url})")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f" Redmine SSO result: still on login page")
|
||||||
|
print(f" Body: {body[:100]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(" No SSO button found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def try_discourse_sso(page, bw):
|
||||||
|
"""Attempt Discourse SSO login."""
|
||||||
|
print("\n=== DISCOURSE SSO ===")
|
||||||
|
page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
dump(page, "discourse-initial")
|
||||||
|
|
||||||
|
# Click "Log In" button to open modal
|
||||||
|
print(" Looking for Log In button...")
|
||||||
|
login_clicked = False
|
||||||
|
for sel in [
|
||||||
|
'.login-button',
|
||||||
|
'.header-buttons .login-button',
|
||||||
|
'button:has-text("Log In")',
|
||||||
|
'[role="button"]:has-text("Log In")',
|
||||||
|
'.btn:has-text("Log In")',
|
||||||
|
'a:has-text("Log In")',
|
||||||
|
]:
|
||||||
|
loc = page.locator(sel)
|
||||||
|
if loc.count() > 0 and loc.first.is_visible():
|
||||||
|
loc.first.click()
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
login_clicked = True
|
||||||
|
print(f" Clicked login button: {sel}")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not login_clicked:
|
||||||
|
print(" Could not find Log In button")
|
||||||
|
dump(page, "discourse-no-login-btn")
|
||||||
|
return False
|
||||||
|
|
||||||
|
dump(page, "discourse-login-modal")
|
||||||
|
|
||||||
|
# Look for SSO/OIDC button inside modal
|
||||||
|
print(" Looking for SSO button in modal...")
|
||||||
|
sso_selectors = [
|
||||||
|
'button:has-text("OpenID")',
|
||||||
|
'button:has-text("Connect")',
|
||||||
|
'button:has-text("Cloud")',
|
||||||
|
'button:has-text("KNEL")',
|
||||||
|
'[class*="oauth"]',
|
||||||
|
'[class*="openid"]',
|
||||||
|
'[class*="sso"]',
|
||||||
|
'a:has-text("OpenID")',
|
||||||
|
'a:has-text("Connect")',
|
||||||
|
'button[class*="social"]',
|
||||||
|
'.login-buttons button',
|
||||||
|
'.auth-buttons button',
|
||||||
|
'[data-login-method]',
|
||||||
|
]
|
||||||
|
|
||||||
|
for sel in sso_selectors:
|
||||||
|
loc = page.locator(sel)
|
||||||
|
if loc.count() > 0 and loc.first.is_visible():
|
||||||
|
print(f" Found SSO button: {sel} ({loc.count()} matches)")
|
||||||
|
loc.first.click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
dump(page, "discourse-after-sso-click")
|
||||||
|
|
||||||
|
# Handle OIDC consent
|
||||||
|
if "openid" in page.url.lower():
|
||||||
|
for consent in ["Continue", "Authorize", "Allow", "Accept"]:
|
||||||
|
cbtn = page.locator(f'[role="button"]:has-text("{consent}"), button:has-text("{consent}")')
|
||||||
|
if cbtn.count() > 0 and cbtn.first.is_visible():
|
||||||
|
cbtn.first.click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
break
|
||||||
|
dump(page, "discourse-after-consent")
|
||||||
|
|
||||||
|
body = page.evaluate("() => document.body.innerText.substring(0, 200)")
|
||||||
|
print(f" Discourse SSO result URL: {page.url}")
|
||||||
|
print(f" Body: {body[:100]}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
print(" No SSO button found in modal")
|
||||||
|
# Dump ALL buttons in the modal for debugging
|
||||||
|
buttons = page.evaluate("""() => {
|
||||||
|
return Array.from(document.querySelectorAll('button, [role="button"], a.btn')).map(el => ({
|
||||||
|
tag: el.tagName,
|
||||||
|
text: (el.textContent || '').trim().substring(0, 50),
|
||||||
|
cls: (el.getAttribute('class') || '').substring(0, 60),
|
||||||
|
visible: el.offsetParent !== null,
|
||||||
|
}));
|
||||||
|
}""")
|
||||||
|
print(f" All buttons: {buttons}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
context = browser.new_context(viewport={"width": 1280, "height": 1024})
|
||||||
|
page = context.new_page()
|
||||||
|
|
||||||
|
print("=== Establishing Cloudron OIDC session ===")
|
||||||
|
cloudron_login(page, bw)
|
||||||
|
|
||||||
|
# Try Redmine SSO
|
||||||
|
redmine_ok = try_redmine_sso(page, bw)
|
||||||
|
|
||||||
|
# Re-establish Cloudron session for Discourse (may have been consumed)
|
||||||
|
print("\n=== Re-establishing Cloudron session ===")
|
||||||
|
cloudron_login(page, bw)
|
||||||
|
|
||||||
|
# Try Discourse SSO
|
||||||
|
discourse_ok = try_discourse_sso(page, bw)
|
||||||
|
|
||||||
|
print(f"\n=== RESULTS ===")
|
||||||
|
print(f" Redmine SSO: {'SUCCESS' if redmine_ok else 'FAILED'}")
|
||||||
|
print(f" Discourse SSO: {'SUCCESS' if discourse_ok else 'FAILED'}")
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
provision-discourse-apikey.py -- Generate Discourse User API key for vp-techops.
|
||||||
|
|
||||||
|
Discourse User API Keys require an RSA-based flow:
|
||||||
|
1. Generate RSA key pair
|
||||||
|
2. Submit public key with the API key request
|
||||||
|
3. User authorizes the request
|
||||||
|
4. Decrypt the returned API key with private key
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker compose run --rm --entrypoint python3 provision provision-discourse-apikey.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||||
|
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")
|
||||||
|
DISCOURSE_URL = os.environ.get("DISCOURSE_URL", "https://community.turnsys.com")
|
||||||
|
STATE_DIR = Path("/app/state")
|
||||||
|
BW_ITEM = "vp-techops Cloudron"
|
||||||
|
DISCOURSE_BW_ITEM = "vp-techops Discourse"
|
||||||
|
EMAIL = "tsgstaff-coo-vptechops@turnsys.com"
|
||||||
|
USERNAME = "vptechops"
|
||||||
|
|
||||||
|
|
||||||
|
def dump(page, label):
|
||||||
|
ts = time.strftime("%H%M%S")
|
||||||
|
try:
|
||||||
|
page.screenshot(path=str(STATE_DIR / f"discourse-apikey-{label}-{ts}.png"), full_page=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
body = page.evaluate("() => document.body.innerText.substring(0, 500)")
|
||||||
|
print(f" [{label}] URL: {page.url}")
|
||||||
|
print(f" Body: {body[:200]}")
|
||||||
|
|
||||||
|
|
||||||
|
def cloudron_login(page, bw):
|
||||||
|
password = bw.get_item_password(BW_ITEM)
|
||||||
|
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=15000)
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
if "login" in page.url.lower() or "openid" in page.url.lower():
|
||||||
|
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)
|
||||||
|
totp = page.query_selector("#inputTotpToken")
|
||||||
|
if totp and totp.is_visible():
|
||||||
|
code = bw.get_totp(BW_ITEM)
|
||||||
|
totp.click()
|
||||||
|
page.keyboard.type(code)
|
||||||
|
page.locator("#totpTokenSubmitButton").click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
|
||||||
|
|
||||||
|
def discourse_sso(page, bw):
|
||||||
|
"""Login to Discourse via SSO."""
|
||||||
|
page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
if page.query_selector("#current-user, .current-user"):
|
||||||
|
return True
|
||||||
|
login_btn = page.locator(".login-button, button:has-text('Log In')")
|
||||||
|
if login_btn.count() > 0:
|
||||||
|
login_btn.first.click()
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
sso_btn = page.locator('button:has-text("OpenID")')
|
||||||
|
if sso_btn.count() > 0:
|
||||||
|
sso_btn.first.click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
if "/signup" in page.url:
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
username_input = page.locator('#new-account-username, input[name="username"]')
|
||||||
|
if username_input.count() > 0 and username_input.first.is_visible():
|
||||||
|
username_input.first.click()
|
||||||
|
page.keyboard.type(USERNAME)
|
||||||
|
page.wait_for_timeout(1000)
|
||||||
|
for btn_text in ["Create Account", "Sign Up", "Register"]:
|
||||||
|
loc = page.locator(f'button:has-text("{btn_text}")')
|
||||||
|
if loc.count() > 0 and loc.first.is_visible():
|
||||||
|
loc.first.click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
break
|
||||||
|
return page.query_selector("#current-user, .current-user") is not None
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
# Generate RSA key pair for User API Key flow
|
||||||
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
public_key = private_key.public_key()
|
||||||
|
|
||||||
|
public_pem = public_key.public_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
).decode("ascii")
|
||||||
|
|
||||||
|
print(f"Generated RSA key pair (public key: {len(public_pem)} bytes)")
|
||||||
|
|
||||||
|
nonce = secrets.token_hex(16)
|
||||||
|
client_id = str(uuid.uuid4())
|
||||||
|
app_name = "TSG-Agent-VP-TechOps"
|
||||||
|
|
||||||
|
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 ===")
|
||||||
|
cloudron_login(page, bw)
|
||||||
|
discourse_sso(page, bw)
|
||||||
|
page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
logged_in = page.query_selector("#current-user, .current-user") is not None
|
||||||
|
print(f" Logged in: {logged_in}")
|
||||||
|
if not logged_in:
|
||||||
|
print(" FAILED to login")
|
||||||
|
browser.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Step 2: Request User API Key with RSA public key
|
||||||
|
print("=== STEP 2: Request User API Key ===")
|
||||||
|
params = (
|
||||||
|
f"?application_name={quote_plus(app_name)}"
|
||||||
|
f"&client_id={client_id}"
|
||||||
|
f"&nonce={nonce}"
|
||||||
|
f"&scopes=read%2Cwrite"
|
||||||
|
f"&public_key={quote_plus(public_pem)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Capture API responses
|
||||||
|
api_responses = []
|
||||||
|
|
||||||
|
def handle_response(response):
|
||||||
|
url = response.url
|
||||||
|
if "user-api-key" in url and response.request.method == "POST":
|
||||||
|
try:
|
||||||
|
api_responses.append(response.text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
page.on("response", handle_response)
|
||||||
|
|
||||||
|
page.goto(
|
||||||
|
f"{DISCOURSE_URL}/user-api-key/new{params}",
|
||||||
|
wait_until="domcontentloaded",
|
||||||
|
timeout=30000,
|
||||||
|
)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
dump(page, "01-apikey-request")
|
||||||
|
|
||||||
|
body = page.evaluate("() => document.body.innerText")
|
||||||
|
print(f" Page body: {body[:300]}")
|
||||||
|
|
||||||
|
# Step 3: Authorize the request
|
||||||
|
print("=== STEP 3: Authorize ===")
|
||||||
|
authorized = False
|
||||||
|
for btn_text in ["Authorize", "Approve", "Continue", "Allow", "Yes"]:
|
||||||
|
loc = page.locator(f'button:has-text("{btn_text}"), [role="button"]:has-text("{btn_text}"), .btn-primary')
|
||||||
|
if loc.count() > 0 and loc.first.is_visible():
|
||||||
|
loc.first.click()
|
||||||
|
authorized = True
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
print(f" Clicked: {btn_text}")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not authorized:
|
||||||
|
# Maybe it's a form with just a submit button
|
||||||
|
submit = page.locator('button[type="submit"], input[type="submit"], .btn-primary')
|
||||||
|
if submit.count() > 0 and submit.first.is_visible():
|
||||||
|
submit.first.click()
|
||||||
|
authorized = True
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
print(" Clicked submit button")
|
||||||
|
|
||||||
|
dump(page, "02-after-authorize")
|
||||||
|
|
||||||
|
# Step 4: Extract and decrypt API key
|
||||||
|
print("=== STEP 4: Extract API key ===")
|
||||||
|
api_key = ""
|
||||||
|
|
||||||
|
# Check captured POST responses
|
||||||
|
for resp_text in api_responses:
|
||||||
|
print(f" Captured response: {resp_text[:200]}")
|
||||||
|
try:
|
||||||
|
data = json.loads(resp_text)
|
||||||
|
encrypted_raw = data.get("key") or data.get("payload") or ""
|
||||||
|
if encrypted_raw:
|
||||||
|
encrypted_clean = encrypted_raw.replace("\n", "").replace("\r", "").replace(" ", "")
|
||||||
|
encrypted_bytes = base64.b64decode(encrypted_clean)
|
||||||
|
print(f" Encrypted payload: {len(encrypted_bytes)} bytes")
|
||||||
|
|
||||||
|
# Try multiple padding schemes (Discourse version-dependent)
|
||||||
|
paddings = [
|
||||||
|
("OAEP-SHA256", padding.OAEP(
|
||||||
|
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
||||||
|
algorithm=hashes.SHA256(), label=None)),
|
||||||
|
("OAEP-SHA1", padding.OAEP(
|
||||||
|
mgf=padding.MGF1(algorithm=hashes.SHA1()),
|
||||||
|
algorithm=hashes.SHA1(), label=None)),
|
||||||
|
("PKCS1v15", padding.PKCS1v15()),
|
||||||
|
]
|
||||||
|
for name, pad in paddings:
|
||||||
|
try:
|
||||||
|
decrypted = private_key.decrypt(encrypted_bytes, pad).decode("ascii")
|
||||||
|
# Decrypted payload is JSON: {"key":"...","nonce":"...","push":false,"api":4}
|
||||||
|
try:
|
||||||
|
key_data = json.loads(decrypted)
|
||||||
|
api_key = key_data.get("key", decrypted)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
api_key = decrypted # fallback: key is plaintext
|
||||||
|
print(f" Decrypted with {name}: {api_key[:12]}...")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Decryption failed: {e}")
|
||||||
|
|
||||||
|
# If no captured response, check page body for JSON or plaintext key
|
||||||
|
if not api_key:
|
||||||
|
body = page.evaluate("() => document.body.innerText")
|
||||||
|
# Try to find and decrypt the encrypted payload in the page
|
||||||
|
# The page shows: "please paste the following key..." followed by base64 RSA-encrypted text
|
||||||
|
key_match = re.search(r'(?:key|application):?\s*\n*\s*([A-Za-z0-9+/\n\r\s={30,}]+)', body)
|
||||||
|
if key_match:
|
||||||
|
encrypted = key_match.group(1).replace("\n", "").replace("\r", "").replace(" ", "").strip()
|
||||||
|
try:
|
||||||
|
api_key = private_key.decrypt(
|
||||||
|
base64.b64decode(encrypted),
|
||||||
|
padding.OAEP(
|
||||||
|
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
label=None,
|
||||||
|
),
|
||||||
|
).decode("ascii")
|
||||||
|
print(f" Decrypted from page body: {api_key[:12]}...")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Look for unencrypted key (fallback)
|
||||||
|
if not api_key:
|
||||||
|
matches = re.findall(r'[a-f0-9]{64}', body)
|
||||||
|
if matches:
|
||||||
|
api_key = matches[0]
|
||||||
|
print(f" Found unencrypted key: {api_key[:12]}...")
|
||||||
|
|
||||||
|
if api_key:
|
||||||
|
print(f"\n API KEY: {api_key}")
|
||||||
|
print("=== STEP 5: Store in Bitwarden ===")
|
||||||
|
existing = bw.get_item_id(DISCOURSE_BW_ITEM)
|
||||||
|
if existing:
|
||||||
|
bw.update_item(DISCOURSE_BW_ITEM, password=api_key)
|
||||||
|
print(f" Updated BW item '{DISCOURSE_BW_ITEM}'")
|
||||||
|
else:
|
||||||
|
bw.create_item(
|
||||||
|
name=DISCOURSE_BW_ITEM,
|
||||||
|
username=USERNAME,
|
||||||
|
password=api_key,
|
||||||
|
uris=[DISCOURSE_URL],
|
||||||
|
collection_name="default",
|
||||||
|
)
|
||||||
|
print(f" Created BW item '{DISCOURSE_BW_ITEM}'")
|
||||||
|
else:
|
||||||
|
print(" Could not extract API key")
|
||||||
|
# Dump all visible elements for debugging
|
||||||
|
body = page.evaluate("() => document.body.innerText")
|
||||||
|
print(f" Full body: {body[:500]}")
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
print("\n=== DONE ===")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
provision-discourse.py -- Complete Discourse SSO signup and API key extraction.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Login to Cloudron panel (establish OIDC session)
|
||||||
|
2. Click Discourse login -> SSO via OpenID Connect
|
||||||
|
3. Complete signup page (enter username)
|
||||||
|
4. Check for API key generation options
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker compose run --rm --entrypoint python3 provision provision-discourse.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, sys, time, re
|
||||||
|
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")
|
||||||
|
DISCOURSE_URL = os.environ.get("DISCOURSE_URL", "https://community.turnsys.com")
|
||||||
|
STATE_DIR = Path("/app/state")
|
||||||
|
BW_ITEM = "vp-techops Cloudron"
|
||||||
|
EMAIL = "tsgstaff-coo-vptechops@turnsys.com"
|
||||||
|
USERNAME = "vptechops"
|
||||||
|
|
||||||
|
|
||||||
|
def dump(page, label):
|
||||||
|
ts = time.strftime("%H%M%S")
|
||||||
|
try:
|
||||||
|
page.screenshot(path=str(STATE_DIR / f"discourse-{label}-{ts}.png"), full_page=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
body = page.evaluate("() => document.body.innerText.substring(0, 500)")
|
||||||
|
print(f" [{label}] URL: {page.url}")
|
||||||
|
print(f" Body: {body[:200]}")
|
||||||
|
|
||||||
|
|
||||||
|
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: Cloudron login
|
||||||
|
print("=== STEP 1: Cloudron login ===")
|
||||||
|
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=15000)
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
if "login" in page.url.lower() or "openid" in page.url.lower():
|
||||||
|
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)
|
||||||
|
totp = page.query_selector("#inputTotpToken")
|
||||||
|
if totp and totp.is_visible():
|
||||||
|
code = bw.get_totp(BW_ITEM)
|
||||||
|
totp.click()
|
||||||
|
page.keyboard.type(code)
|
||||||
|
page.locator("#totpTokenSubmitButton").click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
print(f" Cloudron: {page.url}")
|
||||||
|
|
||||||
|
# Step 2: Discourse SSO
|
||||||
|
print("=== STEP 2: Discourse SSO ===")
|
||||||
|
page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
|
||||||
|
# Click Log In button
|
||||||
|
login_btn = page.locator(".login-button")
|
||||||
|
if login_btn.count() == 0:
|
||||||
|
login_btn = page.locator('button:has-text("Log In")')
|
||||||
|
if login_btn.count() > 0:
|
||||||
|
login_btn.first.click()
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
dump(page, "01-login-modal")
|
||||||
|
|
||||||
|
# Click OpenID Connect button
|
||||||
|
sso_btn = page.locator('button:has-text("OpenID")')
|
||||||
|
if sso_btn.count() > 0:
|
||||||
|
sso_btn.first.click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
dump(page, "02-after-sso")
|
||||||
|
|
||||||
|
# Step 3: Handle signup or login complete
|
||||||
|
print("=== STEP 3: Handle signup/login ===")
|
||||||
|
if "/signup" in page.url:
|
||||||
|
print(" On signup page -- need to create account")
|
||||||
|
# Fill username
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# Look for username input
|
||||||
|
username_input = None
|
||||||
|
for sel in ['#new-account-username', 'input[name="username"]', 'input#new-account-username']:
|
||||||
|
loc = page.locator(sel)
|
||||||
|
if loc.count() > 0 and loc.first.is_visible():
|
||||||
|
username_input = loc.first
|
||||||
|
break
|
||||||
|
|
||||||
|
if username_input:
|
||||||
|
username_input.click()
|
||||||
|
page.keyboard.type(USERNAME)
|
||||||
|
page.wait_for_timeout(1000)
|
||||||
|
print(f" Entered username: {USERNAME}")
|
||||||
|
dump(page, "03-username-entered")
|
||||||
|
|
||||||
|
# Look for create/submit button
|
||||||
|
for btn_text in ["Create Account", "Sign Up", "Register", "Submit", "Continue"]:
|
||||||
|
loc = page.locator(f'button:has-text("{btn_text}"), [role="button"]:has-text("{btn_text}")')
|
||||||
|
if loc.count() > 0 and loc.first.is_visible():
|
||||||
|
loc.first.click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
print(f" Clicked: {btn_text}")
|
||||||
|
break
|
||||||
|
|
||||||
|
dump(page, "04-after-signup")
|
||||||
|
else:
|
||||||
|
print(" Could not find username input")
|
||||||
|
dump(page, "03-no-username-input")
|
||||||
|
elif "/login" in page.url:
|
||||||
|
print(" Back on login page")
|
||||||
|
else:
|
||||||
|
print(" Appears logged in!")
|
||||||
|
|
||||||
|
# Step 4: Check if we're authenticated now
|
||||||
|
print("=== STEP 4: Verify authentication ===")
|
||||||
|
page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
|
||||||
|
# Check for user avatar or logged-in indicators
|
||||||
|
body = page.evaluate("() => document.body.innerText")
|
||||||
|
has_avatar = page.query_selector("#current-user, .current-user, [data-user-card]")
|
||||||
|
is_logged_in = has_avatar is not None or USERNAME in body
|
||||||
|
|
||||||
|
if is_logged_in:
|
||||||
|
print(f" Discourse login SUCCESSFUL!")
|
||||||
|
else:
|
||||||
|
print(f" Discourse login status unclear")
|
||||||
|
dump(page, "05-status-check")
|
||||||
|
|
||||||
|
# Step 5: Check for API key options
|
||||||
|
print("=== STEP 5: Check API key options ===")
|
||||||
|
# Try user API key generation endpoint
|
||||||
|
page.goto(f"{DISCOURSE_URL}/u/{USERNAME}/preferences/account", wait_until="domcontentloaded", timeout=30000)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
dump(page, "06-account-preferences")
|
||||||
|
|
||||||
|
# Look for API key section
|
||||||
|
body = page.evaluate("() => document.body.innerText")
|
||||||
|
if "api key" in body.lower():
|
||||||
|
print(" API key section found!")
|
||||||
|
else:
|
||||||
|
print(" No API key section in user preferences")
|
||||||
|
|
||||||
|
# Try the user API key generation flow
|
||||||
|
page.goto(f"{DISCOURSE_URL}/user-api-key/new", wait_until="domcontentloaded", timeout=30000)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
dump(page, "07-user-api-key")
|
||||||
|
|
||||||
|
body = page.evaluate("() => document.body.innerText")
|
||||||
|
print(f" User API key page: {body[:200]}")
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
print("\n=== DONE ===")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -5,3 +5,4 @@ qrcode==7.4.2
|
|||||||
Pillow==10.4.0
|
Pillow==10.4.0
|
||||||
pyzbar==0.1.9
|
pyzbar==0.1.9
|
||||||
pytest==8.3.2
|
pytest==8.3.2
|
||||||
|
cryptography==44.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user