6b0e51898c
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.
211 lines
8.7 KiB
Python
211 lines
8.7 KiB
Python
"""Player model for digital signage players."""
|
|
from datetime import datetime
|
|
from typing import Optional, List
|
|
|
|
from app.extensions import db
|
|
|
|
|
|
class Player(db.Model):
|
|
"""Player model representing a digital signage device.
|
|
|
|
Attributes:
|
|
id: Primary key
|
|
name: Display name for the player
|
|
hostname: Unique hostname/identifier for the player
|
|
location: Physical location description
|
|
auth_code: Authentication code for API access (legacy)
|
|
password_hash: Hashed password for player authentication
|
|
quickconnect_code: Hashed quick connect code for easy pairing
|
|
orientation: Display orientation (Landscape/Portrait)
|
|
status: Current player status (online, offline, error)
|
|
last_seen: Last activity timestamp
|
|
auth_token_issued_at: When the current auth token was issued
|
|
auth_reset_required: Whether the player must re-authenticate
|
|
playlist_id: Assigned playlist (sync version comes from Playlist.version)
|
|
created_at: Player creation timestamp
|
|
"""
|
|
__tablename__ = 'player'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
name = db.Column(db.String(255), nullable=False)
|
|
hostname = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
|
location = db.Column(db.String(255), nullable=True)
|
|
auth_code = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
|
password_hash = db.Column(db.String(255), nullable=False)
|
|
quickconnect_code = db.Column(db.String(255), nullable=True)
|
|
orientation = db.Column(db.String(16), default='Landscape', nullable=False)
|
|
status = db.Column(db.String(50), default='offline', index=True)
|
|
last_seen = db.Column(db.DateTime, nullable=True, index=True)
|
|
last_heartbeat = db.Column(db.DateTime, nullable=True, index=True)
|
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
|
|
|
# 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 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)
|
|
|
|
# Playlist assignment
|
|
playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'),
|
|
nullable=True, index=True)
|
|
|
|
# Deployment tracking
|
|
deployment_status = db.Column(db.String(50), default='pending', nullable=True) # pending, deployed, failed
|
|
last_deployment_at = db.Column(db.DateTime, nullable=True)
|
|
last_deployment_status = db.Column(db.String(50), nullable=True) # success, failed
|
|
last_deployment_message = db.Column(db.Text, nullable=True)
|
|
|
|
# Relationships
|
|
playlist = db.relationship('Playlist', back_populates='players')
|
|
feedback = db.relationship('PlayerFeedback', back_populates='player',
|
|
cascade='all, delete-orphan', lazy='dynamic')
|
|
|
|
def __repr__(self) -> str:
|
|
"""String representation of Player."""
|
|
return f'<Player {self.name} (ID={self.id}, Status={self.status})>'
|
|
|
|
@property
|
|
def is_online(self) -> bool:
|
|
"""Check if player is online (seen in last 5 minutes)."""
|
|
if not self.last_seen:
|
|
return False
|
|
delta = datetime.utcnow() - self.last_seen
|
|
return delta.total_seconds() < 300 # 5 minutes
|
|
|
|
def update_status(self, status: str) -> None:
|
|
"""Update player status and last seen timestamp.
|
|
|
|
Args:
|
|
status: New status value
|
|
"""
|
|
self.status = status
|
|
self.last_seen = datetime.utcnow()
|
|
|
|
def set_password(self, password: str) -> None:
|
|
"""Set player password with bcrypt hashing.
|
|
|
|
Args:
|
|
password: Plain text password
|
|
"""
|
|
from app.extensions import bcrypt
|
|
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
|
|
|
def check_password(self, password: str) -> bool:
|
|
"""Verify player password.
|
|
|
|
Args:
|
|
password: Plain text password to check
|
|
|
|
Returns:
|
|
True if password matches, False otherwise
|
|
"""
|
|
from app.extensions import bcrypt
|
|
return bcrypt.check_password_hash(self.password_hash, password)
|
|
|
|
def set_quickconnect_code(self, code: str) -> None:
|
|
"""Set quick connect code with bcrypt hashing.
|
|
|
|
Args:
|
|
code: Plain text quick connect code
|
|
"""
|
|
from app.extensions import bcrypt
|
|
self.quickconnect_code = bcrypt.generate_password_hash(code).decode('utf-8')
|
|
|
|
def check_quickconnect_code(self, code: str) -> bool:
|
|
"""Verify quick connect code.
|
|
|
|
Args:
|
|
code: Plain text code to check
|
|
|
|
Returns:
|
|
True if code matches, False otherwise
|
|
"""
|
|
if not self.quickconnect_code:
|
|
return False
|
|
from app.extensions import bcrypt
|
|
return bcrypt.check_password_hash(self.quickconnect_code, code)
|
|
|
|
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()
|
|
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. 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.
|
|
"""
|
|
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]:
|
|
"""Return the age of the current token in days, or ``None`` if unset."""
|
|
if not self.auth_token_issued_at:
|
|
return None
|
|
delta = datetime.utcnow() - self.auth_token_issued_at
|
|
return delta.total_seconds() / 86400
|
|
|
|
@staticmethod
|
|
def authenticate(hostname: str, password: str = None, quickconnect_code: str = None) -> Optional['Player']:
|
|
"""Authenticate a player by hostname and password or quickconnect code.
|
|
|
|
Args:
|
|
hostname: Player hostname
|
|
password: Player password (optional if using quickconnect)
|
|
quickconnect_code: Quick connect code (optional if using password)
|
|
|
|
Returns:
|
|
Player instance if authentication successful, None otherwise
|
|
"""
|
|
player = Player.query.filter_by(hostname=hostname).first()
|
|
if not player:
|
|
return None
|
|
|
|
# Try password authentication first
|
|
if password and player.check_password(password):
|
|
return player
|
|
|
|
# Try quickconnect code authentication
|
|
if quickconnect_code and player.check_quickconnect_code(quickconnect_code):
|
|
return player
|
|
|
|
return None
|