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:
+20
-15
@@ -1585,22 +1585,27 @@ def maintenance_set_interval():
|
||||
@login_required
|
||||
@admin_required
|
||||
def maintenance_reset_token(player_id: int):
|
||||
"""Reset a single player's authentication token."""
|
||||
"""Request that a single player re-authenticate.
|
||||
|
||||
The token is not replaced here (see ``Player.request_reauth``): the player
|
||||
is flagged, told to authenticate again on its next heartbeat, and receives
|
||||
a fresh token at that point.
|
||||
"""
|
||||
try:
|
||||
player = Player.query.get_or_404(player_id)
|
||||
player.rotate_auth_token(force_reauth=True)
|
||||
player.request_reauth()
|
||||
db.session.commit()
|
||||
|
||||
log_action('info',
|
||||
f'Auth token reset for player "{player.name}" '
|
||||
f'Re-authentication requested for player "{player.name}" '
|
||||
f'(ID: {player.id}) by {current_user.username}')
|
||||
flash(f'Token reset for "{player.name}". It will re-authenticate on its '
|
||||
f'next heartbeat.', 'success')
|
||||
flash(f'"{player.name}" will be asked to re-authenticate on its next '
|
||||
f'heartbeat and then receive a new token.', 'success')
|
||||
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error resetting auth token: {exc}')
|
||||
flash('Error resetting the authentication token.', 'danger')
|
||||
log_action('error', f'Error requesting token reset: {exc}')
|
||||
flash('Error requesting the re-authentication.', 'danger')
|
||||
|
||||
return redirect(url_for('admin.maintenance'))
|
||||
|
||||
@@ -1609,23 +1614,23 @@ def maintenance_reset_token(player_id: int):
|
||||
@login_required
|
||||
@admin_required
|
||||
def maintenance_reset_all_tokens():
|
||||
"""Reset the authentication token of every player."""
|
||||
"""Request that every player re-authenticate (see ``request_reauth``)."""
|
||||
try:
|
||||
players = Player.query.all()
|
||||
for player in players:
|
||||
player.rotate_auth_token(force_reauth=True)
|
||||
player.request_reauth()
|
||||
db.session.commit()
|
||||
|
||||
log_action('info',
|
||||
f'Auth tokens reset for all {len(players)} player(s) by '
|
||||
f'{current_user.username}')
|
||||
flash(f'Token reset for {len(players)} player(s). They will '
|
||||
f're-authenticate on their next heartbeat.', 'success')
|
||||
f'Re-authentication requested for all {len(players)} '
|
||||
f'player(s) by {current_user.username}')
|
||||
flash(f'{len(players)} player(s) will be asked to re-authenticate on '
|
||||
f'their next heartbeat and then receive new tokens.', 'success')
|
||||
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error resetting all auth tokens: {exc}')
|
||||
flash('Error resetting the authentication tokens.', 'danger')
|
||||
log_action('error', f'Error requesting all token resets: {exc}')
|
||||
flash('Error requesting the re-authentications.', 'danger')
|
||||
|
||||
return redirect(url_for('admin.maintenance'))
|
||||
|
||||
|
||||
+23
-14
@@ -115,8 +115,12 @@ def player_needs_reauth(player: Player):
|
||||
* an explicit reset flag (``auth_reset_required``) set from the admin
|
||||
maintenance page, individually or for every player; and
|
||||
* the optional automatic expiry interval configured on that page — a token
|
||||
older than ``reset_interval_days`` is rotated and the player is asked to
|
||||
authenticate again.
|
||||
older than ``reset_interval_days`` asks the player to authenticate again.
|
||||
|
||||
Both are *requests*, not enforcement: the flag is raised here and the token
|
||||
is only replaced once the player actually re-authenticates (see
|
||||
``Player.mark_authenticated``). That keeps a player which cannot handle the
|
||||
request working instead of silently losing playlist sync.
|
||||
|
||||
Args:
|
||||
player: The player sending the heartbeat.
|
||||
@@ -139,9 +143,8 @@ def player_needs_reauth(player: Player):
|
||||
return False, None
|
||||
|
||||
if datetime.utcnow() - issued >= timedelta(days=interval_days):
|
||||
# Expire the token now: rotate it and flag the player, so the response
|
||||
# below tells the client to restart authentication.
|
||||
player.rotate_auth_token(force_reauth=True)
|
||||
# Flag the player; the replacement token is issued when it re-auths.
|
||||
player.request_reauth()
|
||||
db.session.commit()
|
||||
return True, 'token_expired'
|
||||
|
||||
@@ -199,10 +202,12 @@ def authenticate_player():
|
||||
log_action('warning', f'Failed authentication attempt for hostname: {hostname}')
|
||||
return jsonify({'error': 'Invalid credentials'}), 401
|
||||
|
||||
# Update player status and stamp the token. Marking the player as
|
||||
# authenticated clears any pending re-authentication request set from the
|
||||
# admin maintenance page, so a player that just re-authenticated stops
|
||||
# being told to authenticate again.
|
||||
# Update player status and finalise the token. If a re-authentication had
|
||||
# been requested (admin reset or expiry interval), this is where the old
|
||||
# token is retired: mark_authenticated() issues a brand-new one. The value
|
||||
# is returned below as `auth_code`, so the player picks it up here. A
|
||||
# player that re-authenticated with no request pending simply gets its
|
||||
# issue time refreshed.
|
||||
player.mark_authenticated()
|
||||
player.update_status('online')
|
||||
db.session.commit()
|
||||
@@ -554,11 +559,15 @@ def receive_player_feedback():
|
||||
return jsonify({'error': 'Invalid quickconnect code'}), 403
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Authentication-token reset.
|
||||
# Re-authentication request.
|
||||
#
|
||||
# When an admin requests a token reset (or an automatic reset interval
|
||||
# elapses), the heartbeat is answered with 401 + auth_required so the
|
||||
# client restarts its authentication flow. The player's existing token
|
||||
# is still valid at this point — it is replaced only once the client
|
||||
# actually re-authenticates — so a player that does not understand this
|
||||
# response keeps syncing instead of breaking.
|
||||
#
|
||||
# When the admin maintenance page resets a player's token (or an
|
||||
# automatic reset interval has elapsed), the heartbeat is answered with
|
||||
# 401 + auth_required so the client restarts its authentication flow.
|
||||
# This runs before any feedback is recorded: the heartbeat is not a
|
||||
# meaningful status update while the client is being asked to
|
||||
# re-authenticate.
|
||||
@@ -571,7 +580,7 @@ def receive_player_feedback():
|
||||
'success': False,
|
||||
'auth_required': True,
|
||||
'reason': reauth_reason,
|
||||
'message': 'Authentication token reset. Please re-authenticate.',
|
||||
'message': 'Re-authentication required. Please authenticate again.',
|
||||
}), 401
|
||||
|
||||
# Create feedback record
|
||||
|
||||
@@ -300,10 +300,12 @@ def regenerate_auth_code(player_id: int):
|
||||
try:
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
# Issue a new auth code and stamp the issue time so the maintenance
|
||||
# page's reset interval measures age from this point. A manual
|
||||
# regenerate keeps its previous behaviour (no forced re-auth).
|
||||
new_auth_code = player.rotate_auth_token(force_reauth=False)
|
||||
# Issue a new auth code immediately and stamp the issue time so the
|
||||
# maintenance page's reset interval measures age from this point. The
|
||||
# admin is shown the new value, so an immediate swap is intended here —
|
||||
# unlike the maintenance page, which only asks the player to re-auth.
|
||||
new_auth_code = player.issue_new_token()
|
||||
player.auth_reset_required = False
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Auth code regenerated for player "{player.name}" (ID: {player_id})')
|
||||
|
||||
Reference in New Issue
Block a user