Archived
Simplify pending setup secrets
This commit is contained in:
1 parent
5fee087b60
commit
8f07bbde84
6 files changed
+29
-23
No files matched your search
@@ -15,6 +15,7 @@ from .settings import DdnsSettings
|
|||||||
DASHBOARD_UNLOCK_COOKIE = "ddns_dashboard_unlock"
|
DASHBOARD_UNLOCK_COOKIE = "ddns_dashboard_unlock"
|
||||||
DASHBOARD_UNLOCK_DEFAULT_SECONDS = 24 * 60 * 60
|
DASHBOARD_UNLOCK_DEFAULT_SECONDS = 24 * 60 * 60
|
||||||
DASHBOARD_UNLOCK_STAY_SIGNED_IN_SECONDS = 30 * 24 * 60 * 60
|
DASHBOARD_UNLOCK_STAY_SIGNED_IN_SECONDS = 30 * 24 * 60 * 60
|
||||||
|
BEARER_SECRET_PREFIX = "karl_"
|
||||||
PRIVATE_PROXY_NETWORKS = tuple(
|
PRIVATE_PROXY_NETWORKS = tuple(
|
||||||
ip_network(network)
|
ip_network(network)
|
||||||
for network in (
|
for network in (
|
||||||
@@ -62,6 +63,10 @@ def hash_lookup_token(token: str) -> str:
|
|||||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def new_bearer_secret() -> str:
|
||||||
|
return f"{BEARER_SECRET_PREFIX}{secrets.token_urlsafe(32)}"
|
||||||
|
|
||||||
|
|
||||||
def sign_dashboard_unlock_cookie(
|
def sign_dashboard_unlock_cookie(
|
||||||
hostname: str,
|
hostname: str,
|
||||||
management_slug: str,
|
management_slug: str,
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from ..security import hash_lookup_token, hash_secret, verify_secret
|
from ..security import hash_lookup_token, hash_secret, new_bearer_secret, verify_secret
|
||||||
from .types import AccountRow
|
from .types import AccountRow
|
||||||
|
|
||||||
|
|
||||||
class AccountsMixin:
|
class AccountsMixin:
|
||||||
def create_account(self, hostname: str, username: str | None = None, *, accepted: bool = True) -> AccountRow:
|
def create_account(self, hostname: str, username: str | None = None, *, accepted: bool = True) -> AccountRow:
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
update_slug = secrets.token_urlsafe(32)
|
update_slug = new_bearer_secret()
|
||||||
management_slug = secrets.token_urlsafe(32)
|
management_slug = new_bearer_secret()
|
||||||
management_hash = hash_lookup_token(management_slug)
|
management_hash = hash_lookup_token(management_slug)
|
||||||
accepted_at = now if accepted else None
|
accepted_at = now if accepted else None
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
@@ -103,13 +102,13 @@ class AccountsMixin:
|
|||||||
conn.execute("DELETE FROM records WHERE hostname = ?", (hostname,))
|
conn.execute("DELETE FROM records WHERE hostname = ?", (hostname,))
|
||||||
|
|
||||||
def rotate_update_slug(self, hostname: str) -> AccountRow | None:
|
def rotate_update_slug(self, hostname: str) -> AccountRow | None:
|
||||||
update_slug = secrets.token_urlsafe(32)
|
update_slug = new_bearer_secret()
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (update_slug, hostname))
|
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (update_slug, hostname))
|
||||||
return self.get_account_by_hostname(hostname)
|
return self.get_account_by_hostname(hostname)
|
||||||
|
|
||||||
def rotate_management_slug(self, hostname: str) -> AccountRow | None:
|
def rotate_management_slug(self, hostname: str) -> AccountRow | None:
|
||||||
management_slug = secrets.token_urlsafe(32)
|
management_slug = new_bearer_secret()
|
||||||
management_hash = hash_lookup_token(management_slug)
|
management_hash = hash_lookup_token(management_slug)
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.execute("UPDATE accounts SET management_hash = ? WHERE hostname = ?", (management_hash, hostname))
|
conn.execute("UPDATE accounts SET management_hash = ? WHERE hostname = ?", (management_hash, hostname))
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..security import hash_lookup_token
|
from ..security import hash_lookup_token, new_bearer_secret
|
||||||
|
|
||||||
BUSY_TIMEOUT_MS = 10_000
|
BUSY_TIMEOUT_MS = 10_000
|
||||||
CACHE_SIZE_KIB = 20_000
|
CACHE_SIZE_KIB = 20_000
|
||||||
@@ -160,11 +159,11 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
|
|||||||
if schema_version < 2:
|
if schema_version < 2:
|
||||||
conn.execute("UPDATE accounts SET accepted_at = created_at WHERE accepted_at IS NULL")
|
conn.execute("UPDATE accounts SET accepted_at = created_at WHERE accepted_at IS NULL")
|
||||||
for row in conn.execute("SELECT hostname FROM accounts WHERE update_slug IS NULL OR update_slug = ''"):
|
for row in conn.execute("SELECT hostname FROM accounts WHERE update_slug IS NULL OR update_slug = ''"):
|
||||||
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (secrets.token_urlsafe(32), row[0]))
|
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (new_bearer_secret(), row[0]))
|
||||||
for row in conn.execute("SELECT hostname FROM accounts WHERE management_hash IS NULL OR management_hash = ''"):
|
for row in conn.execute("SELECT hostname FROM accounts WHERE management_hash IS NULL OR management_hash = ''"):
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE accounts SET management_hash = ? WHERE hostname = ?",
|
"UPDATE accounts SET management_hash = ? WHERE hostname = ?",
|
||||||
(hash_lookup_token(secrets.token_urlsafe(32)), row[0]),
|
(hash_lookup_token(new_bearer_secret()), row[0]),
|
||||||
)
|
)
|
||||||
_migrate_accounts_schema(conn)
|
_migrate_accounts_schema(conn)
|
||||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_update_slug ON accounts(update_slug)")
|
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_update_slug ON accounts(update_slug)")
|
||||||
|
|||||||
@@ -162,7 +162,6 @@ def _render_pending_management_page(
|
|||||||
"management_slug": management_slug,
|
"management_slug": management_slug,
|
||||||
}
|
}
|
||||||
update_url = service.fritz_update_url(account_for_url)
|
update_url = service.fritz_update_url(account_for_url)
|
||||||
registration_url = service.registration_url({"management_slug": management_slug})
|
|
||||||
dashboard_url = service.dashboard_url({"management_slug": management_slug})
|
dashboard_url = service.dashboard_url({"management_slug": management_slug})
|
||||||
hostname = escape(raw_hostname)
|
hostname = escape(raw_hostname)
|
||||||
message_html = _management_message(message)
|
message_html = _management_message(message)
|
||||||
@@ -188,15 +187,13 @@ def _render_pending_management_page(
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Save</p>
|
<p class="eyebrow">Save</p>
|
||||||
<h2>Your values.</h2>
|
<h2>Your values.</h2>
|
||||||
<p class="section-copy">This page is private. You can revisit it while the setup is pending. After registration, the dashboard hides secrets by default.</p>
|
<p class="section-copy">Save the update URL and dashboard link before you register. After registration, the dashboard hides secrets by default.</p>
|
||||||
<p class="section-copy"><strong>Public:</strong> hostname and CNAME target. <strong>Secret:</strong> update URL, register URL, and private dashboard.</p>
|
<p class="section-copy"><strong>Public:</strong> Domainnamen. <strong>Secret:</strong> Update-URL and private dashboard.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
{_copy_row("Update-URL:", update_url, "Secret", mask_secret=False)}
|
{_copy_row("Update-URL:", update_url, "Secret", mask_secret=False)}
|
||||||
{_copy_row("Domainnamen:", raw_hostname, "Public")}
|
{_copy_row("Domainnamen:", raw_hostname, "Public")}
|
||||||
{_copy_row("Register URL:", registration_url, "Secret", mask_secret=False)}
|
|
||||||
{_copy_row("Private dashboard:", dashboard_url, "Secret", mask_secret=False)}
|
{_copy_row("Private dashboard:", dashboard_url, "Secret", mask_secret=False)}
|
||||||
{_copy_row("CNAME target:", raw_hostname, "Public")}
|
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/m/{attr(management_slug)}/activate">
|
<form method="post" action="/m/{attr(management_slug)}/activate">
|
||||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||||
@@ -219,7 +216,7 @@ def _render_pending_management_page(
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2>Use your own DNS name.</h2>
|
<h2>Use your own DNS name.</h2>
|
||||||
<p class="section-copy">Create a CNAME at your DNS provider that points to <strong>{hostname}</strong>. KarlDNS only updates this generated hostname.</p>
|
<p class="section-copy">Create a CNAME at your DNS provider that points to <strong>{hostname}</strong>. This is the same value as Domainnamen.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -161,6 +161,9 @@ def test_hostname_api_lifecycle_and_history(tmp_path: Path) -> None:
|
|||||||
dashboard_secret = body["dashboard_url"].rsplit("/", 1)[1]
|
dashboard_secret = body["dashboard_url"].rsplit("/", 1)[1]
|
||||||
update_path = _path(body["update_url"])
|
update_path = _path(body["update_url"])
|
||||||
register_path = _path(body["registration_url"])
|
register_path = _path(body["registration_url"])
|
||||||
|
assert dashboard_secret.startswith("karl_")
|
||||||
|
assert update_path.rsplit("/", 1)[1].startswith("karl_")
|
||||||
|
assert register_path.rsplit("/", 1)[1].startswith("karl_")
|
||||||
|
|
||||||
pending = client.get(f"/api/v1/hostnames/{dashboard_secret}")
|
pending = client.get(f"/api/v1/hostnames/{dashboard_secret}")
|
||||||
assert pending.status_code == 200
|
assert pending.status_code == 200
|
||||||
@@ -214,6 +217,7 @@ def test_hostname_api_can_rotate_update_url(tmp_path: Path) -> None:
|
|||||||
assert rotated.status_code == 200
|
assert rotated.status_code == 200
|
||||||
assert rotated.json()["hostname"] == body["hostname"]
|
assert rotated.json()["hostname"] == body["hostname"]
|
||||||
assert rotated.json()["update_url"] != body["update_url"]
|
assert rotated.json()["update_url"] != body["update_url"]
|
||||||
|
assert new_update_path.rsplit("/", 1)[1].startswith("karl_")
|
||||||
assert client.get(old_update_path, params={"myip": "203.0.113.11"}).status_code == 404
|
assert client.get(old_update_path, params={"myip": "203.0.113.11"}).status_code == 404
|
||||||
assert client.get(new_update_path, params={"myip": "203.0.113.12"}).status_code == 200
|
assert client.get(new_update_path, params={"myip": "203.0.113.12"}).status_code == 200
|
||||||
|
|
||||||
|
|||||||
+9
-7
@@ -28,8 +28,9 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "/m/" in str(response.url)
|
assert "/m/" in str(response.url)
|
||||||
assert "Update-URL:" in response.text
|
assert "Update-URL:" in response.text
|
||||||
assert "CNAME target:" in response.text
|
assert "Domainnamen:" in response.text
|
||||||
assert "Register URL:" in response.text
|
assert "CNAME target:" not in response.text
|
||||||
|
assert "Register URL:" not in response.text
|
||||||
assert "Router setup." in response.text
|
assert "Router setup." in response.text
|
||||||
assert "Use your own DNS name." in response.text
|
assert "Use your own DNS name." in response.text
|
||||||
assert "Manual updates" in response.text
|
assert "Manual updates" in response.text
|
||||||
@@ -40,7 +41,7 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
|
|||||||
assert "Secret:" in response.text
|
assert "Secret:" in response.text
|
||||||
assert ".ddns.example.net" in response.text
|
assert ".ddns.example.net" in response.text
|
||||||
assert "/api/v1/router/update/" in response.text
|
assert "/api/v1/router/update/" in response.text
|
||||||
assert "/api/v1/router/register/" in response.text
|
assert "/api/v1/router/register/" not in response.text
|
||||||
assert "/m/" in response.text
|
assert "/m/" in response.text
|
||||||
assert 'type="password"' not in response.text
|
assert 'type="password"' not in response.text
|
||||||
assert "Copy setup values" not in response.text
|
assert "Copy setup values" not in response.text
|
||||||
@@ -79,8 +80,9 @@ def test_hostname_without_login_generates_update_and_dashboard_links(tmp_path: P
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "/m/" in str(response.url)
|
assert "/m/" in str(response.url)
|
||||||
assert "Update-URL:" in response.text
|
assert "Update-URL:" in response.text
|
||||||
assert "CNAME target:" in response.text
|
assert "Domainnamen:" in response.text
|
||||||
assert "Register URL:" in response.text
|
assert "CNAME target:" not in response.text
|
||||||
|
assert "Register URL:" not in response.text
|
||||||
assert "Private dashboard:" in response.text
|
assert "Private dashboard:" in response.text
|
||||||
assert "Pending setup" in response.text
|
assert "Pending setup" in response.text
|
||||||
assert "Register hostname" in response.text
|
assert "Register hostname" in response.text
|
||||||
@@ -89,8 +91,8 @@ def test_hostname_without_login_generates_update_and_dashboard_links(tmp_path: P
|
|||||||
assert "Copy setup values" not in response.text
|
assert "Copy setup values" not in response.text
|
||||||
assert "Paste this exact URL into the FRITZ!Box" in response.text
|
assert "Paste this exact URL into the FRITZ!Box" in response.text
|
||||||
assert "Copy manual update commands" in response.text
|
assert "Copy manual update commands" in response.text
|
||||||
assert response.text.count("copy-field-secret") >= 3
|
assert response.text.count("copy-field-secret") >= 2
|
||||||
assert response.text.count("copy-field-public") >= 2
|
assert response.text.count("copy-field-public") >= 1
|
||||||
assert ".ddns.example.net" in response.text
|
assert ".ddns.example.net" in response.text
|
||||||
assert 'type="password"' not in response.text
|
assert 'type="password"' not in response.text
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user