feat: F5.2 sign-in with the dashboard's ticket and the service way
Server F checks the dashboard's entry ticket as itsdangerous does (tools/ticket-oracle: tickets made by itsdangerous 2.2.0 itself), redeems it at /__auth for a session cookie (only the key's SHA-256 is kept) and decides at /sync by the session's grant: none, read or write; a frozen project is read by everyone; every refusal names its reason (one list in packages/protocol/src/sync.ts). A WebSocket from a page of another origin is refused. /svc/* with the service key makes, deletes, freezes and thaws projects, ends a person's grant or all their sessions (connections let go) and lists the sessions. Snapshots, restoring and copying move to F6 (they need the canonical doc.json and an epoch). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.venv/
|
||||
@@ -0,0 +1,22 @@
|
||||
# Orakel für die Eintrittskarte
|
||||
|
||||
Das Dashboard signiert die Eintrittskarte für Server F mit itsdangerous
|
||||
(`URLSafeTimedSerializer`, Salz `mdg-filius-ticket`, 60 s), Server F prüft
|
||||
sie in Node (`packages/server/src/ticket.ts`). Dieses Orakel lässt
|
||||
itsdangerous selbst Karten ausstellen — bei fester Uhr — und hält fest, was
|
||||
es beim Lesen zu einem Zeitpunkt antwortet: die Nutzlast, abgelaufen oder
|
||||
falsch signiert.
|
||||
|
||||
```bash
|
||||
./run.sh # schreibt packages/server/test/tickets.python.json
|
||||
```
|
||||
|
||||
Braucht Python 3; itsdangerous kommt in der Version des Dashboards
|
||||
(`backend/requirements.txt`) in ein eigenes `.venv`.
|
||||
|
||||
Fälle: gewöhnliche Karte (schon sie ist zlib-gepackt), Lesestufe, Umlaute
|
||||
und Emoji, lange Namen, genau 60 s alt, 61 s alt, aus der Zukunft, fremdes
|
||||
Geheimnis, Scratchs Salz, eine veränderte Nutzlast, ohne Signatur,
|
||||
Unsinn, leer, eine Liste statt eines Objekts (ungepackt), Karten ohne
|
||||
Stufe, mit unbekannter Stufe, mit einem Projekt, das keine UUID ist, ohne
|
||||
Namen, nur mit den Pflichtfeldern.
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Writes packages/server/test/tickets.python.json: the dashboard's entry
|
||||
# tickets as itsdangerous makes and reads them (plan §8.5). Needs Python 3;
|
||||
# itsdangerous comes in the dashboard's version into a venv of its own.
|
||||
set -eu
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
root="$(cd "$here/../.." && pwd)"
|
||||
if [ ! -x "$here/.venv/bin/python" ]; then
|
||||
python3 -m venv "$here/.venv"
|
||||
"$here/.venv/bin/pip" install -q itsdangerous==2.2.0
|
||||
fi
|
||||
out="$root/packages/server/test/tickets.python.json"
|
||||
# Removed first: a crashed run must not leave the previous result standing.
|
||||
rm -f "$out"
|
||||
"$here/.venv/bin/python" "$here/tickets.py" "$out"
|
||||
test -s "$out"
|
||||
@@ -0,0 +1,95 @@
|
||||
"""The dashboard's entry tickets, made by itsdangerous itself (plan §8.5).
|
||||
|
||||
The dashboard signs a ticket with itsdangerous' URLSafeTimedSerializer (the
|
||||
same as Scratch's), server F checks it in Node. This writes tickets as the
|
||||
dashboard makes them, with a fixed clock, and what itsdangerous answers when
|
||||
it reads each one back at a given moment: the payload, or which error.
|
||||
packages/server/test/tickets.python.json holds the result; the server's
|
||||
tests read every ticket with the Node check and expect the same answer.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
import itsdangerous
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
from itsdangerous.timed import TimestampSigner
|
||||
|
||||
SECRET = "filius-test-secret"
|
||||
SALT = "mdg-filius-ticket"
|
||||
TTL = 60
|
||||
SIGNED_AT = 1_790_000_000
|
||||
|
||||
clock = {"now": SIGNED_AT}
|
||||
TimestampSigner.get_timestamp = lambda self: clock["now"]
|
||||
|
||||
PERSON = "0b8f5f2e-1a3c-4d5e-8f90-123456789abc"
|
||||
PROJECT = "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6"
|
||||
|
||||
|
||||
def claims(**more):
|
||||
base = {"u": PERSON, "n": "ab12", "d": "Ada Beispiel", "c": "#3366cc", "lang": "de", "p": PROJECT, "lvl": "write"}
|
||||
base.update(more)
|
||||
return base
|
||||
|
||||
|
||||
def sign(payload, secret=SECRET, salt=SALT, at=SIGNED_AT):
|
||||
clock["now"] = at
|
||||
return URLSafeTimedSerializer(secret, salt=salt).dumps(payload)
|
||||
|
||||
|
||||
def answer(ticket, now):
|
||||
clock["now"] = now
|
||||
try:
|
||||
return {"payload": URLSafeTimedSerializer(SECRET, salt=SALT).loads(ticket, max_age=TTL)}
|
||||
except SignatureExpired:
|
||||
return {"error": "expired"}
|
||||
except BadSignature:
|
||||
return {"error": "badSignature"}
|
||||
|
||||
|
||||
def tampered(ticket):
|
||||
# One letter of the payload changed (the first after its first two).
|
||||
letter = ticket[2]
|
||||
return ticket[:2] + ("A" if letter != "A" else "B") + ticket[3:]
|
||||
|
||||
|
||||
def main(out):
|
||||
plain = sign(claims())
|
||||
long_name = "Maximiliane Sophie Charlotte von und zu Beispielhausen-Musterstein"
|
||||
cases = [
|
||||
("plain", plain, SIGNED_AT),
|
||||
("readLevel", sign(claims(lvl="read")), SIGNED_AT),
|
||||
("umlautsAndEmoji", sign(claims(d="Jörg Ömer Weiß 🐧", lang="en")), SIGNED_AT),
|
||||
("compressed", sign(claims(d=long_name, n=long_name.lower().replace(" ", "."))), SIGNED_AT),
|
||||
("sixtySecondsOld", plain, SIGNED_AT + 60),
|
||||
("sixtyOneSecondsOld", plain, SIGNED_AT + 61),
|
||||
("fromTheFuture", plain, SIGNED_AT - 1),
|
||||
("otherSecret", sign(claims(), secret="another-secret"), SIGNED_AT),
|
||||
("scratchSalt", sign(claims(), salt="mdg-scratch-ticket"), SIGNED_AT),
|
||||
("tamperedPayload", tampered(plain), SIGNED_AT),
|
||||
("noSignature", plain.rsplit(".", 1)[0], SIGNED_AT),
|
||||
("garbage", "not a ticket", SIGNED_AT),
|
||||
("empty", "", SIGNED_AT),
|
||||
("listPayload", sign(["u", "p"]), SIGNED_AT),
|
||||
("withoutLevel", sign({k: v for k, v in claims().items() if k != "lvl"}), SIGNED_AT),
|
||||
("unknownLevel", sign(claims(lvl="admin")), SIGNED_AT),
|
||||
("projectNotAUuid", sign(claims(p="../etc")), SIGNED_AT),
|
||||
("withoutName", sign({k: v for k, v in claims().items() if k not in ("n", "d")}), SIGNED_AT),
|
||||
("onlyRequired", sign({"u": PERSON, "n": "ab12", "p": PROJECT, "lvl": "read"}), SIGNED_AT),
|
||||
]
|
||||
result = {
|
||||
"itsdangerous": __import__("importlib.metadata").metadata.version("itsdangerous"),
|
||||
"python": sys.version.split()[0],
|
||||
"secret": SECRET,
|
||||
"salt": SALT,
|
||||
"ttl": TTL,
|
||||
"signedAt": SIGNED_AT,
|
||||
"cases": [{"name": name, "ticket": ticket, "now": now, **answer(ticket, now)} for name, ticket, now in cases],
|
||||
}
|
||||
with open(out, "w", encoding="utf-8") as file:
|
||||
json.dump(result, file, ensure_ascii=False, indent=2)
|
||||
file.write("\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1])
|
||||
Reference in New Issue
Block a user