Make player token reset non-breaking (deferred rotation)

The maintenance-page reset used to rotate auth_code immediately. Because the
token also guards the playlist endpoints, that stopped a player from syncing
content the instant the button was pressed -- before the player ever had a
chance to react -- and clients that do not understand the heartbeat's
401 auth_required would have stayed broken until redeployed.

Split the model behaviour:

* request_reauth()  -- sets the flag only (used by the admin reset endpoints
  and the automatic interval). The current token stays valid, so there is no
  outage while the player is being asked to re-authenticate.
* issue_new_token() -- replaces the token now (used by the existing
  "regenerate auth code" action, where the admin is shown the new value).
* mark_authenticated() -- if a reset was pending, this is where the old token
  is retired: a new token is issued and returned to the player in the
  /api/auth/player response.

Net effect: the reset is a request the player acts on, never a forced logout.
A player that ignores it keeps working and shows as "awaiting re-auth" in the
UI; one that handles it gets a fresh token and the old one stops working.

Maintenance page wording updated to match (request vs reset; "awaiting
re-auth" badge), plus a themed info note explaining it is not a forced logout.

Adds docs/player-auth-reset.md describing the protocol and exactly what the
player client (Kiwy-Signage) still needs to implement: detect 401 /
auth_required on the heartbeat (and on the token-guarded endpoints), re-run
POST /api/auth/player with the stored quick-connect code, save the new
auth_code and retry. No password or SSH redeploy required.

Verified end-to-end: token unchanged and still accepted after the request
(200), heartbeat answers 401 auth_required=token_reset, re-auth issues a
different token, the old token is then rejected (403) and the new one accepted
(200), flag cleared; the interval path only flags without rotating.
This commit is contained in:
2026-10-14 23:14:11 +03:00
parent 4dc8506c9b
commit 6b0e51898c
6 changed files with 287 additions and 62 deletions
+40 -21
View File
@@ -41,8 +41,10 @@ class Player(db.Model):
# Authentication token lifecycle. ``auth_token_issued_at`` records when the
# current ``auth_code`` was handed out, so the maintenance page can expire
# tokens by age. ``auth_reset_required`` is set when the token has been
# invalidated; the next heartbeat then tells the player to re-authenticate.
# tokens by age. ``auth_reset_required`` is set when a re-authentication has
# been requested (by an admin, or by the automatic interval); the next
# heartbeat then tells the player to authenticate again, and the token is
# replaced at the moment it does. See :meth:`request_reauth`.
auth_token_issued_at = db.Column(db.DateTime, nullable=True)
auth_reset_required = db.Column(db.Boolean, default=False, nullable=False)
@@ -126,35 +128,52 @@ class Player(db.Model):
from app.extensions import bcrypt
return bcrypt.check_password_hash(self.quickconnect_code, code)
def rotate_auth_token(self, force_reauth: bool = True) -> str:
"""Issue a brand new authentication token for this player.
Rotating the token invalidates any token the player currently holds:
endpoints guarded by ``verify_player_auth`` will start returning 401
for the old value. Combined with ``auth_reset_required`` this makes the
player restart its authentication flow on the next heartbeat.
Args:
force_reauth: When True (default) also flag the player so the next
heartbeat asks it to re-authenticate.
def issue_new_token(self) -> str:
"""Replace the authentication token immediately.
The old token stops working at once, so this is only safe when the
caller can hand the new value straight to the player — i.e. during
authentication itself, or when an admin explicitly asks for a new code
to be shown in the UI. To *ask* a running player to re-authenticate,
use :meth:`request_reauth` instead.
Returns:
The newly generated auth code.
"""
import secrets
self.auth_code = secrets.token_urlsafe(16)
self.auth_token_issued_at = datetime.utcnow()
if force_reauth:
self.auth_reset_required = True
return self.auth_code
def request_reauth(self) -> None:
"""Ask the player to re-authenticate on its next heartbeat.
Deliberately *deferred*: the current token is left valid and only the
``auth_reset_required`` flag is set. The heartbeat then answers
``401 {auth_required: true}``, and when the player re-authenticates a
brand-new token is issued (:meth:`mark_authenticated`), at which point
the old one finally stops working.
Why not rotate at once: the token guards the playlist endpoints, so
rotating it immediately would stop a player from syncing content the
moment the admin pressed the button — before the player ever had the
chance to re-authenticate. Deferring makes the operation non-breaking
for players that do not (yet) react to the request.
"""
self.auth_reset_required = True
def mark_authenticated(self) -> None:
"""Record a successful authentication.
Stamps the token issue time and clears any pending reset request, so a
player that re-authenticates stops being told to authenticate again.
Stamps the token issue time and clears any pending reset request. If a
reset *was* pending, this is the point where the old token dies: a
brand-new token is generated (available afterwards as ``auth_code``)
so the caller's response hands the player a fresh credential.
"""
self.auth_token_issued_at = datetime.utcnow()
if self.auth_reset_required:
self.issue_new_token()
else:
self.auth_token_issued_at = datetime.utcnow()
self.auth_reset_required = False
def auth_token_age_days(self) -> Optional[float]: