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
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def maintenance_reset_token(player_id: int):
|
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:
|
try:
|
||||||
player = Player.query.get_or_404(player_id)
|
player = Player.query.get_or_404(player_id)
|
||||||
player.rotate_auth_token(force_reauth=True)
|
player.request_reauth()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
log_action('info',
|
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}')
|
f'(ID: {player.id}) by {current_user.username}')
|
||||||
flash(f'Token reset for "{player.name}". It will re-authenticate on its '
|
flash(f'"{player.name}" will be asked to re-authenticate on its next '
|
||||||
f'next heartbeat.', 'success')
|
f'heartbeat and then receive a new token.', 'success')
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
log_action('error', f'Error resetting auth token: {exc}')
|
log_action('error', f'Error requesting token reset: {exc}')
|
||||||
flash('Error resetting the authentication token.', 'danger')
|
flash('Error requesting the re-authentication.', 'danger')
|
||||||
|
|
||||||
return redirect(url_for('admin.maintenance'))
|
return redirect(url_for('admin.maintenance'))
|
||||||
|
|
||||||
@@ -1609,23 +1614,23 @@ def maintenance_reset_token(player_id: int):
|
|||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def maintenance_reset_all_tokens():
|
def maintenance_reset_all_tokens():
|
||||||
"""Reset the authentication token of every player."""
|
"""Request that every player re-authenticate (see ``request_reauth``)."""
|
||||||
try:
|
try:
|
||||||
players = Player.query.all()
|
players = Player.query.all()
|
||||||
for player in players:
|
for player in players:
|
||||||
player.rotate_auth_token(force_reauth=True)
|
player.request_reauth()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
log_action('info',
|
log_action('info',
|
||||||
f'Auth tokens reset for all {len(players)} player(s) by '
|
f'Re-authentication requested for all {len(players)} '
|
||||||
f'{current_user.username}')
|
f'player(s) by {current_user.username}')
|
||||||
flash(f'Token reset for {len(players)} player(s). They will '
|
flash(f'{len(players)} player(s) will be asked to re-authenticate on '
|
||||||
f're-authenticate on their next heartbeat.', 'success')
|
f'their next heartbeat and then receive new tokens.', 'success')
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
log_action('error', f'Error resetting all auth tokens: {exc}')
|
log_action('error', f'Error requesting all token resets: {exc}')
|
||||||
flash('Error resetting the authentication tokens.', 'danger')
|
flash('Error requesting the re-authentications.', 'danger')
|
||||||
|
|
||||||
return redirect(url_for('admin.maintenance'))
|
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
|
* an explicit reset flag (``auth_reset_required``) set from the admin
|
||||||
maintenance page, individually or for every player; and
|
maintenance page, individually or for every player; and
|
||||||
* the optional automatic expiry interval configured on that page — a token
|
* the optional automatic expiry interval configured on that page — a token
|
||||||
older than ``reset_interval_days`` is rotated and the player is asked to
|
older than ``reset_interval_days`` asks the player to authenticate again.
|
||||||
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:
|
Args:
|
||||||
player: The player sending the heartbeat.
|
player: The player sending the heartbeat.
|
||||||
@@ -139,9 +143,8 @@ def player_needs_reauth(player: Player):
|
|||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
if datetime.utcnow() - issued >= timedelta(days=interval_days):
|
if datetime.utcnow() - issued >= timedelta(days=interval_days):
|
||||||
# Expire the token now: rotate it and flag the player, so the response
|
# Flag the player; the replacement token is issued when it re-auths.
|
||||||
# below tells the client to restart authentication.
|
player.request_reauth()
|
||||||
player.rotate_auth_token(force_reauth=True)
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return True, 'token_expired'
|
return True, 'token_expired'
|
||||||
|
|
||||||
@@ -199,10 +202,12 @@ def authenticate_player():
|
|||||||
log_action('warning', f'Failed authentication attempt for hostname: {hostname}')
|
log_action('warning', f'Failed authentication attempt for hostname: {hostname}')
|
||||||
return jsonify({'error': 'Invalid credentials'}), 401
|
return jsonify({'error': 'Invalid credentials'}), 401
|
||||||
|
|
||||||
# Update player status and stamp the token. Marking the player as
|
# Update player status and finalise the token. If a re-authentication had
|
||||||
# authenticated clears any pending re-authentication request set from the
|
# been requested (admin reset or expiry interval), this is where the old
|
||||||
# admin maintenance page, so a player that just re-authenticated stops
|
# token is retired: mark_authenticated() issues a brand-new one. The value
|
||||||
# being told to authenticate again.
|
# 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.mark_authenticated()
|
||||||
player.update_status('online')
|
player.update_status('online')
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -554,11 +559,15 @@ def receive_player_feedback():
|
|||||||
return jsonify({'error': 'Invalid quickconnect code'}), 403
|
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
|
# This runs before any feedback is recorded: the heartbeat is not a
|
||||||
# meaningful status update while the client is being asked to
|
# meaningful status update while the client is being asked to
|
||||||
# re-authenticate.
|
# re-authenticate.
|
||||||
@@ -571,7 +580,7 @@ def receive_player_feedback():
|
|||||||
'success': False,
|
'success': False,
|
||||||
'auth_required': True,
|
'auth_required': True,
|
||||||
'reason': reauth_reason,
|
'reason': reauth_reason,
|
||||||
'message': 'Authentication token reset. Please re-authenticate.',
|
'message': 'Re-authentication required. Please authenticate again.',
|
||||||
}), 401
|
}), 401
|
||||||
|
|
||||||
# Create feedback record
|
# Create feedback record
|
||||||
|
|||||||
@@ -300,10 +300,12 @@ def regenerate_auth_code(player_id: int):
|
|||||||
try:
|
try:
|
||||||
player = Player.query.get_or_404(player_id)
|
player = Player.query.get_or_404(player_id)
|
||||||
|
|
||||||
# Issue a new auth code and stamp the issue time so the maintenance
|
# Issue a new auth code immediately and stamp the issue time so the
|
||||||
# page's reset interval measures age from this point. A manual
|
# maintenance page's reset interval measures age from this point. The
|
||||||
# regenerate keeps its previous behaviour (no forced re-auth).
|
# admin is shown the new value, so an immediate swap is intended here —
|
||||||
new_auth_code = player.rotate_auth_token(force_reauth=False)
|
# 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()
|
db.session.commit()
|
||||||
|
|
||||||
log_action('info', f'Auth code regenerated for player "{player.name}" (ID: {player_id})')
|
log_action('info', f'Auth code regenerated for player "{player.name}" (ID: {player_id})')
|
||||||
|
|||||||
+40
-21
@@ -41,8 +41,10 @@ class Player(db.Model):
|
|||||||
|
|
||||||
# Authentication token lifecycle. ``auth_token_issued_at`` records when the
|
# Authentication token lifecycle. ``auth_token_issued_at`` records when the
|
||||||
# current ``auth_code`` was handed out, so the maintenance page can expire
|
# 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
|
# tokens by age. ``auth_reset_required`` is set when a re-authentication has
|
||||||
# invalidated; the next heartbeat then tells the player to re-authenticate.
|
# 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_token_issued_at = db.Column(db.DateTime, nullable=True)
|
||||||
auth_reset_required = db.Column(db.Boolean, default=False, nullable=False)
|
auth_reset_required = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
@@ -126,35 +128,52 @@ class Player(db.Model):
|
|||||||
from app.extensions import bcrypt
|
from app.extensions import bcrypt
|
||||||
return bcrypt.check_password_hash(self.quickconnect_code, code)
|
return bcrypt.check_password_hash(self.quickconnect_code, code)
|
||||||
|
|
||||||
def rotate_auth_token(self, force_reauth: bool = True) -> str:
|
def issue_new_token(self) -> str:
|
||||||
"""Issue a brand new authentication token for this player.
|
"""Replace the authentication token immediately.
|
||||||
|
|
||||||
Rotating the token invalidates any token the player currently holds:
|
The old token stops working at once, so this is only safe when the
|
||||||
endpoints guarded by ``verify_player_auth`` will start returning 401
|
caller can hand the new value straight to the player — i.e. during
|
||||||
for the old value. Combined with ``auth_reset_required`` this makes the
|
authentication itself, or when an admin explicitly asks for a new code
|
||||||
player restart its authentication flow on the next heartbeat.
|
to be shown in the UI. To *ask* a running player to re-authenticate,
|
||||||
|
use :meth:`request_reauth` instead.
|
||||||
Args:
|
|
||||||
force_reauth: When True (default) also flag the player so the next
|
|
||||||
heartbeat asks it to re-authenticate.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The newly generated auth code.
|
The newly generated auth code.
|
||||||
"""
|
"""
|
||||||
import secrets
|
import secrets
|
||||||
self.auth_code = secrets.token_urlsafe(16)
|
self.auth_code = secrets.token_urlsafe(16)
|
||||||
self.auth_token_issued_at = datetime.utcnow()
|
self.auth_token_issued_at = datetime.utcnow()
|
||||||
if force_reauth:
|
|
||||||
self.auth_reset_required = True
|
|
||||||
return self.auth_code
|
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:
|
def mark_authenticated(self) -> None:
|
||||||
"""Record a successful authentication.
|
"""Record a successful authentication.
|
||||||
|
|
||||||
Stamps the token issue time and clears any pending reset request, so a
|
Stamps the token issue time and clears any pending reset request. If a
|
||||||
player that re-authenticates stops being told to authenticate again.
|
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
|
self.auth_reset_required = False
|
||||||
|
|
||||||
def auth_token_age_days(self) -> Optional[float]:
|
def auth_token_age_days(self) -> Optional[float]:
|
||||||
|
|||||||
@@ -27,6 +27,23 @@
|
|||||||
color: #92400e;
|
color: #92400e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Informational note (e.g. the "this is a request, not a forced logout"
|
||||||
|
explanation), themed so it stays readable in both modes. */
|
||||||
|
.maint-note {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: #e7f3ff;
|
||||||
|
border-left: 4px solid #0066cc;
|
||||||
|
color: #0c5460;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .maint-note {
|
||||||
|
background: #1e3a5f;
|
||||||
|
border-left-color: #90cdf4;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
body.dark-mode .maint-badge-pending {
|
body.dark-mode .maint-badge-pending {
|
||||||
background: #4a3800;
|
background: #4a3800;
|
||||||
color: #fbbf24;
|
color: #fbbf24;
|
||||||
@@ -93,9 +110,16 @@
|
|||||||
<!-- ================= Player Authentication Tokens ================= -->
|
<!-- ================= Player Authentication Tokens ================= -->
|
||||||
<div class="card" style="margin-bottom: 30px;">
|
<div class="card" style="margin-bottom: 30px;">
|
||||||
<h2>🔑 Player Authentication Tokens</h2>
|
<h2>🔑 Player Authentication Tokens</h2>
|
||||||
<p style="color: #6c757d; margin-bottom: 20px;">
|
<p style="color: #6c757d; margin-bottom: 12px;">
|
||||||
Resetting a token invalidates the player's current credentials. On its next
|
Requests that a player authenticate again. The player is told on its next
|
||||||
heartbeat the player is told to authenticate again and receives a fresh token.
|
heartbeat and receives a fresh token when it does — its current token stays
|
||||||
|
valid until then, so playlist syncing is not interrupted.
|
||||||
|
</p>
|
||||||
|
<p class="maint-note" style="margin-bottom: 20px;">
|
||||||
|
ℹ️ This asks the player to re-authenticate; it is not a forced logout. A player
|
||||||
|
whose software does not yet handle the request keeps working on its existing
|
||||||
|
token and simply stays <em>awaiting re-auth</em> below. Players do not need
|
||||||
|
a password — the quick-connect code they already hold is enough.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method="POST" action="{{ url_for('admin.maintenance_set_interval') }}"
|
<form method="POST" action="{{ url_for('admin.maintenance_set_interval') }}"
|
||||||
@@ -124,9 +148,9 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form method="POST" action="{{ url_for('admin.maintenance_reset_all_tokens') }}"
|
<form method="POST" action="{{ url_for('admin.maintenance_reset_all_tokens') }}"
|
||||||
onsubmit="return confirm('Reset the authentication token for ALL players? Each player will re-authenticate on its next heartbeat.');"
|
onsubmit="return confirm('Ask ALL players to re-authenticate? Each one will be told on its next heartbeat and then receive a new token. Their current tokens keep working until then.');"
|
||||||
style="margin-bottom: 20px;">
|
style="margin-bottom: 20px;">
|
||||||
<button type="submit" class="btn btn-danger">🔁 Reset All Player Tokens</button>
|
<button type="submit" class="btn btn-danger">🔁 Request Re-auth for All Players</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div style="max-height: 460px; overflow-y: auto;">
|
<div style="max-height: 460px; overflow-y: auto;">
|
||||||
@@ -169,7 +193,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td style="padding: 10px;">
|
<td style="padding: 10px;">
|
||||||
{% if player.auth_reset_required %}
|
{% if player.auth_reset_required %}
|
||||||
<span class="maint-badge-pending">pending</span>
|
<span class="maint-badge-pending" title="The player has been asked to re-authenticate; the token is replaced once it does.">awaiting re-auth</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span style="color: var(--text-secondary); font-size: 12px;">—</span>
|
<span style="color: var(--text-secondary); font-size: 12px;">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -178,9 +202,9 @@
|
|||||||
<form method="POST"
|
<form method="POST"
|
||||||
action="{{ url_for('admin.maintenance_reset_token', player_id=player.id) }}"
|
action="{{ url_for('admin.maintenance_reset_token', player_id=player.id) }}"
|
||||||
style="display: inline;"
|
style="display: inline;"
|
||||||
onsubmit="return confirm('Reset the token for {{ player.name }}?');">
|
onsubmit="return confirm('Ask {{ player.name }} to re-authenticate?');">
|
||||||
<button type="submit" class="btn btn-warning btn-sm"
|
<button type="submit" class="btn btn-warning btn-sm"
|
||||||
style="padding: 4px 10px; font-size: 12px;">🔁 Reset</button>
|
style="padding: 4px 10px; font-size: 12px;">🔁 Re-auth</button>
|
||||||
</form>
|
</form>
|
||||||
{% if player.auth_reset_required %}
|
{% if player.auth_reset_required %}
|
||||||
<form method="POST"
|
<form method="POST"
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# Player authentication & token re-authentication
|
||||||
|
|
||||||
|
Server-side implementation reference for the player client
|
||||||
|
(`Kiwy-Signage`, https://gitea.moto-adv.com/ske087/Kiwy-Signage.git).
|
||||||
|
|
||||||
|
This document describes how the DigiServer side behaves so the player can be
|
||||||
|
updated to match. **The server side is complete; the client still needs to
|
||||||
|
handle the re-authentication request** (see "What the player must implement").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. How a player authenticates today
|
||||||
|
|
||||||
|
The player reads `config/app_config.json` (written by the SSH deploy step),
|
||||||
|
which contains:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server_ip": "192.168.0.159",
|
||||||
|
"port": "443",
|
||||||
|
"screen_name": "rpi-test1", // == Player.hostname on the server
|
||||||
|
"quickconnect_key": "AB12CD", // == Player.quickconnect_code (plaintext)
|
||||||
|
"orientation": "Landscape",
|
||||||
|
"use_https": true,
|
||||||
|
"verify_ssl": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the player builds URLs as `{scheme}://{server_ip}:{port}/api/...` — it does
|
||||||
|
**not** use any path prefix.
|
||||||
|
|
||||||
|
### Authentication flow (documented in `docs/05-blueprints-api.md`)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant P as Player
|
||||||
|
participant A as DigiServer
|
||||||
|
P->>A: POST /api/auth/player {hostname, quickconnect_code}
|
||||||
|
A-->>P: {auth_code, player_id, playlist_id, orientation}
|
||||||
|
Note over P: stores auth_code (player_auth.json)
|
||||||
|
P->>A: GET /api/playlists/<player_id> (Bearer auth_code)
|
||||||
|
A-->>P: playlist + playlist_version
|
||||||
|
loop refresh poll
|
||||||
|
P->>A: GET /api/playlist-version/<player_id> (Bearer auth_code)
|
||||||
|
A-->>P: {playlist_version, content_count}
|
||||||
|
end
|
||||||
|
loop heartbeat (~15s)
|
||||||
|
P->>A: POST /api/player-feedback {hostname, status, ...}
|
||||||
|
A-->>P: {success: true}
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Which endpoints use the token
|
||||||
|
|
||||||
|
| Endpoint | Auth |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/auth/player` | hostname + password **or** quickconnect code (no token) |
|
||||||
|
| `GET /api/playlists?hostname=&quickconnect_code=` | quickconnect code (no token) |
|
||||||
|
| `GET /api/playlists/<player_id>` | **Bearer `auth_code`** |
|
||||||
|
| `GET /api/playlist-version/<player_id>` | **Bearer `auth_code`** |
|
||||||
|
| `POST /api/player-edit-media` | **Bearer `auth_code`** |
|
||||||
|
| `POST /api/player-feedback` (heartbeat) | none — the player is looked up by hostname |
|
||||||
|
|
||||||
|
Because the token guards the playlist endpoints, **an invalid token stops
|
||||||
|
content sync**, not just auth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Re-authentication request (what changed on the server)
|
||||||
|
|
||||||
|
An admin can request re-authentication from **Admin → Maintenance → Player
|
||||||
|
Authentication Tokens**, either per player or for all players. There is also an
|
||||||
|
optional automatic interval (days) after which the request fires on its own.
|
||||||
|
|
||||||
|
A re-authentication request is **deferred** — the server does *not* invalidate
|
||||||
|
the current token:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Admin
|
||||||
|
participant A as DigiServer
|
||||||
|
participant P as Player
|
||||||
|
Admin->>A: request re-auth
|
||||||
|
A->>A: auth_reset_required = true (token unchanged)
|
||||||
|
P->>A: POST /api/player-feedback
|
||||||
|
A-->>P: 401 {auth_required: true, reason: "token_reset"}
|
||||||
|
Note over P: ⚠️ CLIENT ACTION REQUIRED HERE
|
||||||
|
P->>A: POST /api/auth/player {hostname, quickconnect_code}
|
||||||
|
A->>A: issue NEW token, clear flag
|
||||||
|
A-->>P: {auth_code: NEW}
|
||||||
|
Note over P: store new auth_code, retry requests
|
||||||
|
P->>A: GET /api/playlists/<id> (Bearer NEW) ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
|
||||||
|
* Until the player re-authenticates, its **old token remains valid** — so a
|
||||||
|
player that ignores the request keeps syncing content (no outage).
|
||||||
|
* The new token is issued **during** `POST /api/auth/player`, and returned in
|
||||||
|
the `auth_code` field of the response, exactly like first-time setup.
|
||||||
|
* The player needs **no password**: the `quickconnect_key` already in
|
||||||
|
`app_config.json` is sufficient.
|
||||||
|
* No SSH, restart, or redeploy is required.
|
||||||
|
* The admin UI shows the player as **awaiting re-auth** until this completes;
|
||||||
|
the admin can also **Cancel** the request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. What the player must implement
|
||||||
|
|
||||||
|
Today, if the client never handles the request, nothing breaks — but the reset
|
||||||
|
never happens (the player keeps its old token forever and the admin UI stays on
|
||||||
|
"awaiting re-auth").
|
||||||
|
|
||||||
|
To make the reset effective, the player client should:
|
||||||
|
|
||||||
|
1. **Detect the request.** On the heartbeat response, treat
|
||||||
|
`HTTP 401` with `auth_required: true` as "re-authenticate", e.g.:
|
||||||
|
|
||||||
|
```python
|
||||||
|
resp = requests.post(f"{base}/api/player-feedback", json=payload, timeout=10)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
body = resp.json() if resp.content else {}
|
||||||
|
if body.get("auth_required"):
|
||||||
|
reauthenticate(reason=body.get("reason")) # "token_reset" | "token_expired"
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Also handle 401 on the token-guarded calls.** If the token is ever
|
||||||
|
rejected by `/api/playlists/<id>` or `/api/playlist-version/<id>`, run the
|
||||||
|
same re-authentication path. This makes the client self-healing even if the
|
||||||
|
heartbeat was missed.
|
||||||
|
|
||||||
|
3. **Re-authenticate using the stored quick-connect code:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def reauthenticate():
|
||||||
|
cfg = load_app_config() # config/app_config.json
|
||||||
|
r = requests.post(f"{base}/api/auth/player", json={
|
||||||
|
"hostname": cfg["screen_name"],
|
||||||
|
"quickconnect_code": cfg["quickconnect_key"],
|
||||||
|
}, timeout=15)
|
||||||
|
r.raise_for_status()
|
||||||
|
save_auth(r.json()["auth_code"]) # player_auth.json
|
||||||
|
# then resume: fetch playlist + version with the new token
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Retry the failed request** with the new token so sync resumes seamlessly.
|
||||||
|
|
||||||
|
5. **Be idempotent / back off.** While a request is pending the heartbeat keeps
|
||||||
|
answering `401`; avoid a tight retry loop (e.g. re-authenticate at most once
|
||||||
|
per N seconds, or immediately then resume the normal heartbeat cadence).
|
||||||
|
|
||||||
|
### Recommended: also add `re-auth` to the edit-media path
|
||||||
|
|
||||||
|
`POST /api/player-edit-media` is token-guarded too, so the same 401 →
|
||||||
|
re-authenticate → retry wrapper should apply there.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Manual alternative (no client change needed)
|
||||||
|
|
||||||
|
Redeploying the player via **Admin → Build Player Files / Deploy** rewrites
|
||||||
|
`config/app_config.json` and deletes `player_auth.json`, which forces a clean
|
||||||
|
authentication on next start. This is the deterministic path and is what the
|
||||||
|
maintenance reset is intended to replace (it is remote and non-disruptive).
|
||||||
Reference in New Issue
Block a user