mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
refactor(admin): remove the unreachable /api/setup endpoint
Generated-password bootstrap now sets an admin password on every fresh node, so
the /admin/api/setup success path is unreachable — it 409s ("already set") on
any booted node. No caller exists across the admin UI, routstr-cli, routstr-sdk,
routstrd, or routstr-chat, so the endpoint (and its SetupRequest model) are dead
surface. First-run is now: the generated password is printed once at boot, log
in via /admin, and change it from the dashboard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
eb6a612e5a
commit
f47e16aa61
@@ -310,29 +310,6 @@ async def update_nsec(request: Request, payload: NsecUpdate) -> dict[str, object
|
|||||||
return {"ok": True, "npub": npub}
|
return {"ok": True, "npub": npub}
|
||||||
|
|
||||||
|
|
||||||
class SetupRequest(BaseModel):
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.post("/api/setup")
|
|
||||||
async def initial_setup(request: Request, payload: SetupRequest) -> dict[str, object]:
|
|
||||||
async with create_session() as session:
|
|
||||||
secret = await get_secret(session)
|
|
||||||
if secret.admin_password_hash:
|
|
||||||
raise HTTPException(status_code=409, detail="Admin password already set")
|
|
||||||
pw = (payload.password or "").strip()
|
|
||||||
if len(pw) < vault.MIN_PASSWORD_LENGTH:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=(
|
|
||||||
"Password must be at least "
|
|
||||||
f"{vault.MIN_PASSWORD_LENGTH} characters"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await set_admin_password(session, pw)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
class AdminLoginRequest(BaseModel):
|
class AdminLoginRequest(BaseModel):
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""Tests for admin password auth backed by the hashed Secret store (issue #553).
|
"""Tests for admin password auth backed by the hashed Secret store (issue #553).
|
||||||
|
|
||||||
Login, password change and first-run setup verify against the one-way
|
Login and password change verify against the one-way ``Secret.admin_password_hash``
|
||||||
``Secret.admin_password_hash`` (scrypt) instead of a plaintext settings field,
|
(scrypt) instead of a plaintext settings field, which also closes the old ``!=``
|
||||||
which also closes the old ``!=`` timing-attack comparison. The flow is exercised
|
timing-attack comparison. The first hash is written by ``bootstrap_secrets`` at
|
||||||
end-to-end through the public admin endpoints: setup writes the first hash,
|
startup (generated, or migrated from a legacy password); here it is seeded
|
||||||
login checks against it, and a password change re-hashes so the old password
|
directly into the store, then login checks against it and a password change
|
||||||
stops working and the new one starts.
|
re-hashes so the old password stops working and the new one starts.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -13,10 +13,14 @@ from __future__ import annotations
|
|||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient, Response
|
from httpx import AsyncClient, Response
|
||||||
|
|
||||||
|
from routstr.core.db import create_session, set_admin_password
|
||||||
|
|
||||||
async def _setup_password(client: AsyncClient, password: str) -> None:
|
|
||||||
resp = await client.post("/admin/api/setup", json={"password": password})
|
async def _seed_password(password: str) -> None:
|
||||||
assert resp.status_code == 200, resp.text
|
# bootstrap_secrets owns first-password creation at startup; tests seed the
|
||||||
|
# hash straight into the store the same way, then exercise login/change.
|
||||||
|
async with create_session() as session:
|
||||||
|
await set_admin_password(session, password)
|
||||||
|
|
||||||
|
|
||||||
async def _login(client: AsyncClient, password: str) -> Response:
|
async def _login(client: AsyncClient, password: str) -> Response:
|
||||||
@@ -40,7 +44,7 @@ async def test_login_500_when_no_password_configured(
|
|||||||
async def test_login_succeeds_with_correct_password(
|
async def test_login_succeeds_with_correct_password(
|
||||||
integration_client: AsyncClient,
|
integration_client: AsyncClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
await _setup_password(integration_client, "correct horse")
|
await _seed_password("correct horse")
|
||||||
resp = await _login(integration_client, "correct horse")
|
resp = await _login(integration_client, "correct horse")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
@@ -53,35 +57,11 @@ async def test_login_succeeds_with_correct_password(
|
|||||||
async def test_login_rejects_wrong_password(
|
async def test_login_rejects_wrong_password(
|
||||||
integration_client: AsyncClient,
|
integration_client: AsyncClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
await _setup_password(integration_client, "correct horse")
|
await _seed_password("correct horse")
|
||||||
resp = await _login(integration_client, "wrong horse")
|
resp = await _login(integration_client, "wrong horse")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
# --- first-run setup -------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_setup_rejects_short_password(
|
|
||||||
integration_client: AsyncClient,
|
|
||||||
) -> None:
|
|
||||||
resp = await integration_client.post("/admin/api/setup", json={"password": "short"})
|
|
||||||
assert resp.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_setup_409_when_password_already_set(
|
|
||||||
integration_client: AsyncClient,
|
|
||||||
) -> None:
|
|
||||||
await _setup_password(integration_client, "first password")
|
|
||||||
resp = await integration_client.post(
|
|
||||||
"/admin/api/setup", json={"password": "second password"}
|
|
||||||
)
|
|
||||||
assert resp.status_code == 409
|
|
||||||
|
|
||||||
|
|
||||||
# --- password change -------------------------------------------------------
|
# --- password change -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -90,7 +70,7 @@ async def test_setup_409_when_password_already_set(
|
|||||||
async def test_update_password_rehashes_so_only_new_works(
|
async def test_update_password_rehashes_so_only_new_works(
|
||||||
integration_client: AsyncClient,
|
integration_client: AsyncClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
await _setup_password(integration_client, "old password")
|
await _seed_password("old password")
|
||||||
login = await _login(integration_client, "old password")
|
login = await _login(integration_client, "old password")
|
||||||
token = login.json()["token"]
|
token = login.json()["token"]
|
||||||
integration_client.headers["Authorization"] = f"Bearer {token}"
|
integration_client.headers["Authorization"] = f"Bearer {token}"
|
||||||
@@ -112,7 +92,7 @@ async def test_update_password_rehashes_so_only_new_works(
|
|||||||
async def test_update_password_rejects_wrong_current(
|
async def test_update_password_rejects_wrong_current(
|
||||||
integration_client: AsyncClient,
|
integration_client: AsyncClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
await _setup_password(integration_client, "old password")
|
await _seed_password("old password")
|
||||||
login = await _login(integration_client, "old password")
|
login = await _login(integration_client, "old password")
|
||||||
token = login.json()["token"]
|
token = login.json()["token"]
|
||||||
integration_client.headers["Authorization"] = f"Bearer {token}"
|
integration_client.headers["Authorization"] = f"Bearer {token}"
|
||||||
@@ -129,7 +109,7 @@ async def test_update_password_rejects_wrong_current(
|
|||||||
async def test_update_password_rejects_short_new(
|
async def test_update_password_rejects_short_new(
|
||||||
integration_client: AsyncClient,
|
integration_client: AsyncClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
await _setup_password(integration_client, "old password")
|
await _seed_password("old password")
|
||||||
login = await _login(integration_client, "old password")
|
login = await _login(integration_client, "old password")
|
||||||
token = login.json()["token"]
|
token = login.json()["token"]
|
||||||
integration_client.headers["Authorization"] = f"Bearer {token}"
|
integration_client.headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|||||||
Reference in New Issue
Block a user