SSO and turn gate
This commit is contained in:
@@ -51,6 +51,7 @@ CHAT_HISTORY_LIMIT = 100
|
||||
CHAT_COOLDOWN_MS = 2000
|
||||
PRESENCE_TTL_SECONDS = 45
|
||||
MAX_PRESENCE_TABS_PER_SESSION = 10
|
||||
OTHER_PLAYERS_REQUIRED_BETWEEN_PRESSES = 2
|
||||
OIDC_ISSUER = os.environ.get("BUTTON_OIDC_ISSUER", "").rstrip("/")
|
||||
OIDC_CLIENT_ID = os.environ.get("BUTTON_OIDC_CLIENT_ID", "")
|
||||
OIDC_CLIENT_SECRET = os.environ.get("BUTTON_OIDC_CLIENT_SECRET", "")
|
||||
@@ -61,10 +62,10 @@ OIDC_SUBJECT_LINKS = json.loads(os.environ.get("BUTTON_OIDC_SUBJECT_LINKS_JSON",
|
||||
OIDC_REGISTER_URL = os.environ.get("BUTTON_OIDC_REGISTER_URL", "")
|
||||
OIDC_ADMIN_SUBJECTS = set(json.loads(os.environ.get("BUTTON_OIDC_ADMIN_SUBJECTS_JSON", "") or "[]"))
|
||||
OIDC_COOKIE_SECURE = os.environ.get("BUTTON_OIDC_COOKIE_SECURE", "1").lower() in {"1", "true", "yes"}
|
||||
OIDC_ENABLED = bool(OIDC_ISSUER or OIDC_CLIENT_ID or OIDC_CLIENT_SECRET or OIDC_REDIRECT_URI)
|
||||
if OIDC_ENABLED and not all((OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_REQUIRED_GROUP)):
|
||||
raise RuntimeError("OIDC requires BUTTON_OIDC_ISSUER, BUTTON_OIDC_CLIENT_ID, BUTTON_OIDC_CLIENT_SECRET, BUTTON_OIDC_REDIRECT_URI, and BUTTON_OIDC_REQUIRED_GROUP")
|
||||
if OIDC_ENABLED and urlparse(OIDC_ISSUER).scheme != "https" and os.environ.get("BUTTON_OIDC_ALLOW_HTTP", "0") != "1":
|
||||
if not all((OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_REQUIRED_GROUP)):
|
||||
raise RuntimeError("Dex OIDC is required; configure BUTTON_OIDC_ISSUER, BUTTON_OIDC_CLIENT_ID, BUTTON_OIDC_CLIENT_SECRET, BUTTON_OIDC_REDIRECT_URI, and BUTTON_OIDC_REQUIRED_GROUP")
|
||||
OIDC_ENABLED = True
|
||||
if urlparse(OIDC_ISSUER).scheme != "https" and os.environ.get("BUTTON_OIDC_ALLOW_HTTP", "0") != "1":
|
||||
raise RuntimeError("OIDC issuer must use HTTPS (HTTP is test-only via BUTTON_OIDC_ALLOW_HTTP=1)")
|
||||
|
||||
# Ordered from a freshly reset countdown to an almost-expired one.
|
||||
@@ -229,6 +230,36 @@ def zone_for_remaining(remaining_ms):
|
||||
return ZONES[-1]
|
||||
|
||||
|
||||
def press_eligibility(pid):
|
||||
"""Require two distinct other accounts to press between a player's turns."""
|
||||
last_press_index = next(
|
||||
(index for index in range(len(state["presses"]) - 1, -1, -1)
|
||||
if state["presses"][index]["player_id"] == pid),
|
||||
None,
|
||||
)
|
||||
if last_press_index is None:
|
||||
return {
|
||||
"eligible": True,
|
||||
"has_pressed": False,
|
||||
"other_players_required": OTHER_PLAYERS_REQUIRED_BETWEEN_PRESSES,
|
||||
"other_players_pressed": 0,
|
||||
"other_players_remaining": 0,
|
||||
}
|
||||
other_players = {
|
||||
press["player_id"] for press in state["presses"][last_press_index + 1:]
|
||||
if press["player_id"] != pid
|
||||
}
|
||||
pressed = min(len(other_players), OTHER_PLAYERS_REQUIRED_BETWEEN_PRESSES)
|
||||
remaining = OTHER_PLAYERS_REQUIRED_BETWEEN_PRESSES - pressed
|
||||
return {
|
||||
"eligible": remaining == 0,
|
||||
"has_pressed": True,
|
||||
"other_players_required": OTHER_PLAYERS_REQUIRED_BETWEEN_PRESSES,
|
||||
"other_players_pressed": pressed,
|
||||
"other_players_remaining": remaining,
|
||||
}
|
||||
|
||||
|
||||
def new_game():
|
||||
t = now_ms()
|
||||
return {
|
||||
@@ -649,9 +680,12 @@ def issue_oidc_player(claims, groups):
|
||||
if OIDC_REQUIRED_GROUP and OIDC_REQUIRED_GROUP not in groups:
|
||||
raise PermissionError("This account is not enabled for The Button")
|
||||
subject = claims["sub"]
|
||||
username = claims.get("preferred_username") or claims.get("username")
|
||||
display_name = claims.get("displayName") or claims.get("name")
|
||||
username = display_name if isinstance(display_name, str) and display_name.strip() else (
|
||||
claims.get("uid") or claims.get("preferred_username") or claims.get("username")
|
||||
)
|
||||
if not isinstance(username, str):
|
||||
username = claims["email"].split("@", 1)[0]
|
||||
raise ValueError("The identity provider did not supply a display name or uid")
|
||||
username = " ".join(username.split())
|
||||
if not username or len(username) > MAX_NAME_LEN or not username.isprintable():
|
||||
raise ValueError("The identity provider username is not a valid player name")
|
||||
@@ -672,23 +706,30 @@ def issue_oidc_player(claims, groups):
|
||||
raise ValueError("Player is already linked to another OIDC subject")
|
||||
state["players"][pid]["oidc_sub"] = subject
|
||||
else:
|
||||
if find_player_by_name(username) is not None:
|
||||
raise ValueError("This player name already exists and needs an administrator-managed account link")
|
||||
pid = secrets.token_hex(8)
|
||||
state["players"][pid] = {
|
||||
"name": username,
|
||||
"password_hash": None,
|
||||
"oidc_sub": subject,
|
||||
"token_hashes": [],
|
||||
"created_at": now_ms(),
|
||||
"subscribed": False,
|
||||
"is_admin": False,
|
||||
"is_banned": False,
|
||||
"is_deleted": False,
|
||||
}
|
||||
player = state["players"].get(pid)
|
||||
if not player or player.get("is_banned") or player.get("is_deleted"):
|
||||
pid = None
|
||||
player = state["players"].get(pid) if pid is not None else None
|
||||
if player and (player.get("is_banned") or player.get("is_deleted")):
|
||||
raise PermissionError("This account cannot access The Button")
|
||||
name_owner = find_player_by_name(username)
|
||||
if name_owner is not None and name_owner != pid:
|
||||
raise ValueError("This player name already exists and needs an administrator-managed account link")
|
||||
if pid is None:
|
||||
pid = secrets.token_hex(8)
|
||||
state["players"][pid] = {
|
||||
"name": username,
|
||||
"password_hash": None,
|
||||
"oidc_sub": subject,
|
||||
"token_hashes": [],
|
||||
"created_at": now_ms(),
|
||||
"subscribed": False,
|
||||
"is_admin": False,
|
||||
"is_banned": False,
|
||||
"is_deleted": False,
|
||||
}
|
||||
player = state["players"][pid]
|
||||
if not player:
|
||||
raise PermissionError("This account cannot access The Button")
|
||||
player["name"] = username
|
||||
if OIDC_ADMIN_GROUP or OIDC_ADMIN_SUBJECTS:
|
||||
is_admin = subject in OIDC_ADMIN_SUBJECTS or bool(OIDC_ADMIN_GROUP and OIDC_ADMIN_GROUP in groups)
|
||||
else:
|
||||
@@ -1197,9 +1238,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
path = parsed_request.path
|
||||
if path == "/api/config":
|
||||
return self.send_json(HTTPStatus.OK, {
|
||||
"auth_mode": "oidc" if OIDC_ENABLED else "local",
|
||||
"login_url": "/auth/oidc/login" if OIDC_ENABLED else "",
|
||||
"register_url": OIDC_REGISTER_URL if OIDC_ENABLED else "",
|
||||
"auth_mode": "oidc",
|
||||
"login_url": "/auth/oidc/login",
|
||||
"register_url": OIDC_REGISTER_URL,
|
||||
})
|
||||
if path == "/auth/oidc/login":
|
||||
if not OIDC_ENABLED:
|
||||
@@ -1281,6 +1322,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if path == "/api/state":
|
||||
with lock:
|
||||
check_ended()
|
||||
pid = self.authed_player()
|
||||
return self.send_json(HTTPStatus.OK, {
|
||||
"now": now_ms(),
|
||||
"started_at": state["started_at"],
|
||||
@@ -1291,6 +1333,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"total_presses": len(state["presses"]),
|
||||
"zones": ZONES,
|
||||
"season": season_summary(),
|
||||
"press_eligibility": press_eligibility(pid) if pid is not None else None,
|
||||
})
|
||||
if path == "/api/seasons":
|
||||
with lock:
|
||||
@@ -1505,6 +1548,17 @@ class Handler(BaseHTTPRequestHandler):
|
||||
check_ended(t)
|
||||
if state["ended"]:
|
||||
return self.send_json(HTTPStatus.CONFLICT, {"error": "The game is over"})
|
||||
eligibility = press_eligibility(pid)
|
||||
if not eligibility["eligible"]:
|
||||
remaining_players = eligibility["other_players_remaining"]
|
||||
message = (
|
||||
f"Wait for {remaining_players} more distinct "
|
||||
f"{'player' if remaining_players == 1 else 'players'} to press before your next turn."
|
||||
)
|
||||
return self.send_json(HTTPStatus.CONFLICT, {
|
||||
"error": message,
|
||||
"press_eligibility": eligibility,
|
||||
})
|
||||
remaining = state["deadline"] - t
|
||||
zone = zone_for_remaining(remaining)
|
||||
press = {
|
||||
@@ -1542,6 +1596,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"press": {k: press[k] for k in ("at", "remaining_ms", "zone", "points")},
|
||||
"deadline": state["deadline"],
|
||||
"now": t,
|
||||
"press_eligibility": press_eligibility(pid),
|
||||
})
|
||||
|
||||
self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"})
|
||||
|
||||
Reference in New Issue
Block a user