The first account can only be made in a 30-minute setup window
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 38s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 38s
Family idea #5105, practice 8 (Scribe #5113), the operator's choice of setup window over a setup code. Before this, whoever reached /register first on an empty server became its admin. On a fresh server at a public address, that could be a stranger, and a new DNS name is found within minutes. Now the first registration is refused once 30 minutes have passed since the server started (create_app records STARTED_AT). A restart opens the window again. It is a constant rather than a Setting, because there is no admin yet to change one. Once an account exists it no longer matters, so existing servers are unaffected. public-hosting.md says so, and two integration tests cover both sides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,12 @@ itself: the first account created becomes the admin *and* closes registration be
|
|||||||
it, so there is no window between "my account exists" and "I remembered to turn it
|
it, so there is no window between "my account exists" and "I remembered to turn it
|
||||||
off". A brand-new instance is never locked out of itself, and never left open either.
|
off". A brand-new instance is never locked out of itself, and never left open either.
|
||||||
|
|
||||||
|
**The first account has to be made within 30 minutes of the server starting.** After
|
||||||
|
that, `/register` refuses the first account. Otherwise a fresh server on a public
|
||||||
|
address would belong to whoever found it first. If you miss the window, restart the
|
||||||
|
container and register straight away. Once an account exists, the window no longer
|
||||||
|
matters.
|
||||||
|
|
||||||
**Instances that predate this still need one manual flip.** The close fires when the
|
**Instances that predate this still need one manual flip.** The close fires when the
|
||||||
first account is created, so a server whose admin already existed keeps whatever
|
first account is created, so a server whose admin already existed keeps whatever
|
||||||
`allow_registration` was set to — which was **on** by default. Check **Settings →
|
`allow_registration` was set to — which was **on** by default. Check **Settings →
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
@@ -64,6 +65,8 @@ class _AutoSecureSessionInterface(SecureCookieSessionInterface):
|
|||||||
def create_app() -> Quart:
|
def create_app() -> Quart:
|
||||||
# static_folder=None: the SPA catch-all below owns static serving.
|
# static_folder=None: the SPA catch-all below owns static serving.
|
||||||
app = Quart(__name__, static_folder=None)
|
app = Quart(__name__, static_folder=None)
|
||||||
|
# When this server started, for the first-account setup window (auth.py).
|
||||||
|
app.config["STARTED_AT"] = time.monotonic()
|
||||||
# Ephemeral/env secret so the app (and DB-free unit tests) construct without a
|
# Ephemeral/env secret so the app (and DB-free unit tests) construct without a
|
||||||
# database. before_serving swaps in the real, DB-persisted key before serving.
|
# database. before_serving swaps in the real, DB-persisted key before serving.
|
||||||
app.secret_key = Config.secret_key_env() or secrets.token_urlsafe(48)
|
app.secret_key = Config.secret_key_env() or secrets.token_urlsafe(48)
|
||||||
|
|||||||
+21
-3
@@ -2,10 +2,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from quart import Blueprint, g, jsonify, request, session
|
from quart import Blueprint, current_app, g, jsonify, request, session
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
|
|
||||||
from .common import iso
|
from .common import iso
|
||||||
@@ -45,6 +46,20 @@ SESSION_KEY = "user_id"
|
|||||||
# no key and reads as 0, the epoch every account started at.
|
# no key and reads as 0, the epoch every account started at.
|
||||||
EPOCH_KEY = "epoch"
|
EPOCH_KEY = "epoch"
|
||||||
MIN_PASSWORD_LEN = 8
|
MIN_PASSWORD_LEN = 8
|
||||||
|
|
||||||
|
# The first account can only be created this long after the server starts (family
|
||||||
|
# idea #5105, practice 8). Without it, a fresh server on a public address belongs to
|
||||||
|
# whoever reaches /register first, and a new DNS name is found within minutes.
|
||||||
|
#
|
||||||
|
# A constant, not a Setting: there is no admin yet to change one. Anyone who misses
|
||||||
|
# the window restarts the server, which opens it again; once an account exists the
|
||||||
|
# window no longer matters. Measured from `create_app` (STARTED_AT), so each start
|
||||||
|
# counts afresh.
|
||||||
|
SETUP_WINDOW_S = 30 * 60
|
||||||
|
SETUP_CLOSED = (
|
||||||
|
"this server's setup window has closed. Restart the server, then create the "
|
||||||
|
"first account within 30 minutes"
|
||||||
|
)
|
||||||
DEVICE_NAME_CAP = 100
|
DEVICE_NAME_CAP = 100
|
||||||
|
|
||||||
|
|
||||||
@@ -228,8 +243,11 @@ async def register():
|
|||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
user_count = await db.scalar(select(func.count()).select_from(User)) or 0
|
user_count = await db.scalar(select(func.count()).select_from(User)) or 0
|
||||||
is_first = user_count == 0
|
is_first = user_count == 0
|
||||||
# The first account bootstraps the admin and is always allowed, even when
|
if is_first and time.monotonic() - current_app.config["STARTED_AT"] > SETUP_WINDOW_S:
|
||||||
# registration is otherwise closed. After that, an invite lets one person in
|
logger.warning("registration refused (setup window closed) email=%s from=%s", email, client_address())
|
||||||
|
return json_error(SETUP_CLOSED, 403)
|
||||||
|
# The first account bootstraps the admin and is allowed, inside the setup
|
||||||
|
# window above, even when registration is otherwise closed. After that, an invite lets one person in
|
||||||
# while it is closed (#5172). One that was offered is redeemed even while
|
# while it is closed (#5172). One that was offered is redeemed even while
|
||||||
# registration is open, so the list still says who used it, and one that
|
# registration is open, so the list still says who used it, and one that
|
||||||
# doesn't hold is refused rather than ignored.
|
# doesn't hold is refused rather than ignored.
|
||||||
|
|||||||
@@ -958,6 +958,28 @@ async def _admin_with_invite(app_client, **body) -> str:
|
|||||||
return (await resp.get_json())["token"]
|
return (await resp.get_json())["token"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_first_account_is_refused_once_the_setup_window_has_closed(db):
|
||||||
|
"""A fresh server on a public address must not belong to whoever finds it first
|
||||||
|
(family idea #5105, practice 8). Past the window, nobody becomes admin."""
|
||||||
|
from inkwell.auth import SETUP_WINDOW_S
|
||||||
|
|
||||||
|
ratelimit.reset_all()
|
||||||
|
app = create_app()
|
||||||
|
app.config["STARTED_AT"] -= SETUP_WINDOW_S + 1
|
||||||
|
resp = await app.test_client().post(
|
||||||
|
"/api/auth/register", json={"email": "late@example.test", "password": _PASSWORD}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert "setup window" in (await resp.get_json())["error"]
|
||||||
|
assert await db.scalar(select(func.count()).select_from(User)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_first_account_is_allowed_inside_the_setup_window(app_client, db):
|
||||||
|
resp = await app_client.post("/api/auth/register", json={"email": "prompt@example.test", "password": _PASSWORD})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert (await resp.get_json())["is_admin"] is True
|
||||||
|
|
||||||
|
|
||||||
async def _register(email: str, invite: str | None = None):
|
async def _register(email: str, invite: str | None = None):
|
||||||
"""Register from a separate client, so the admin's session stays where it is."""
|
"""Register from a separate client, so the admin's session stays where it is."""
|
||||||
body = {"email": email, "password": _PASSWORD}
|
body = {"email": email, "password": _PASSWORD}
|
||||||
|
|||||||
Reference in New Issue
Block a user