0.3.22 - PINs

This commit is contained in:
ponzischeme89
2026-08-25 11:39:55 +12:00
parent 396d35e2f5
commit 0fcc02f57e
2697 changed files with 5360 additions and 50 deletions
+1
View File
@@ -19,6 +19,7 @@ const LoginRetention = 90 * 24 * time.Hour
// them apart without inferring it from the device name.
const (
LoginMethodPassword = "password" // a television exchanging Emby credentials
LoginMethodPIN = "pin" // a television recovering a stored device session
LoginMethodAdmin = "admin" // an operator signing into the admin console
LoginMethodInstaller = "installer" // the web installer's own password check
)
+9
View File
@@ -945,10 +945,19 @@ CREATE TABLE IF NOT EXISTS viewers (
colour TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL, -- main | shadow
pin_hash BYTEA,
pin_value TEXT,
pin_failed_attempts INTEGER NOT NULL DEFAULT 0,
pin_locked_until TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Failed PINs are kept with the viewer rather than in the television. This survives
-- reinstalls and gives every device sharing a profile the same brute-force budget.
ALTER TABLE viewers ADD COLUMN IF NOT EXISTS pin_failed_attempts INTEGER NOT NULL DEFAULT 0;
ALTER TABLE viewers ADD COLUMN IF NOT EXISTS pin_locked_until TIMESTAMPTZ;
ALTER TABLE viewers ADD COLUMN IF NOT EXISTS pin_value TEXT;
CREATE INDEX IF NOT EXISTS viewers_account_idx ON viewers (emby_user_id, created_at);
-- One main viewer per account, enforced rather than assumed: the main viewer is what a
+121
View File
@@ -38,6 +38,19 @@ type Viewer struct {
CreatedAt time.Time `json:"createdAt"`
}
type DeviceRecoveryProfile struct {
DeviceID string
DeviceName string
UserID string
Username string
ServerID string
Viewer Viewer
EmbyToken string
}
var ErrPINLocked = errors.New("store: pin temporarily locked")
var ErrPINInvalid = errors.New("store: invalid pin")
// IsMain reports whether this viewer's state is published to Emby.
func (v Viewer) IsMain() bool { return v.Kind == ViewerMain }
@@ -89,6 +102,114 @@ func (s *Store) Viewers(ctx context.Context, embyUserID, username string) ([]Vie
return viewers, rows.Err()
}
// DeviceRecoveryProfiles is deliberately based on the surviving gateway session, not
// on IP address or device name. A reinstall loses local credentials but not this record.
func (s *Store) DeviceRecoveryProfiles(ctx context.Context, deviceID string) ([]DeviceRecoveryProfile, error) {
rows, err := s.pool.Query(ctx, `
SELECT s.device_id, s.device_name, s.emby_user_id, s.username, s.server_id, s.emby_token
FROM sessions s WHERE s.device_id = $1 ORDER BY s.last_seen_at DESC LIMIT 1`, deviceID)
if err != nil {
return nil, fmt.Errorf("store: device recovery profiles: %w", err)
}
defer rows.Close()
profiles := []DeviceRecoveryProfile{}
for rows.Next() {
var p DeviceRecoveryProfile
if err := rows.Scan(&p.DeviceID, &p.DeviceName, &p.UserID, &p.Username, &p.ServerID, &p.EmbyToken); err != nil {
return nil, fmt.Errorf("store: scan recovery profile: %w", err)
}
viewers, err := s.Viewers(ctx, p.UserID, p.Username)
if err != nil {
return nil, err
}
for _, viewer := range viewers {
copy := p
copy.Viewer = viewer
profiles = append(profiles, copy)
}
}
return profiles, rows.Err()
}
func (s *Store) SetViewerPIN(ctx context.Context, embyUserID, viewerID string, hash []byte) error {
result, err := s.pool.Exec(ctx, `UPDATE viewers SET pin_hash = $3, pin_failed_attempts = 0,
pin_locked_until = NULL, updated_at = now() WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID, hash)
if err != nil {
return fmt.Errorf("store: set viewer pin: %w", err)
}
if result.RowsAffected() == 0 {
return ErrViewerNotFound
}
return nil
}
func (s *Store) SetViewerPINValue(ctx context.Context, embyUserID, viewerID, pin string) error {
_, err := s.pool.Exec(ctx, `UPDATE viewers SET pin_value = $3 WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID, nilIfEmpty(pin))
return err
}
func nilIfEmpty(value string) any {
if value == "" {
return nil
}
return value
}
func (s *Store) ClearViewerPIN(ctx context.Context, embyUserID, viewerID string) error {
return s.SetViewerPIN(ctx, embyUserID, viewerID, nil)
}
// ViewerPIN is intentionally not part of Viewer: only the admin handler may ask for it.
func (s *Store) ViewerPIN(ctx context.Context, embyUserID, viewerID string) (string, error) {
var pin *string
err := s.pool.QueryRow(ctx, `SELECT pin_value FROM viewers WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID).Scan(&pin)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrViewerNotFound
}
if err != nil {
return "", err
}
if pin == nil {
return "", nil
}
return *pin, nil
}
// CheckViewerPIN applies the failed-attempt budget. Ten failures are not
// permanent revocation; they are a short server-side pause which survives a reinstall.
func (s *Store) CheckViewerPIN(ctx context.Context, embyUserID, viewerID string, valid func([]byte) bool) (bool, error) {
var hash []byte
var failures int
var lockedUntil *time.Time
err := s.pool.QueryRow(ctx, `SELECT pin_hash, pin_failed_attempts, pin_locked_until
FROM viewers WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID).Scan(&hash, &failures, &lockedUntil)
if errors.Is(err, pgx.ErrNoRows) {
return false, ErrViewerNotFound
}
if err != nil {
return false, fmt.Errorf("store: read viewer pin: %w", err)
}
if len(hash) == 0 {
return true, nil
}
if lockedUntil != nil && time.Now().Before(*lockedUntil) {
return false, ErrPINLocked
}
if valid(hash) {
_, err = s.pool.Exec(ctx, `UPDATE viewers SET pin_failed_attempts = 0, pin_locked_until = NULL WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID)
return err == nil, err
}
failures++
if failures >= 5 {
lockedUntil = func() *time.Time { t := time.Now().Add(time.Duration(failures-4) * 15 * time.Second); return &t }()
}
_, err = s.pool.Exec(ctx, `UPDATE viewers SET pin_failed_attempts = $3, pin_locked_until = $4 WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID, failures, lockedUntil)
if err != nil {
return false, err
}
return false, ErrPINInvalid
}
// ensureMainViewer records the account's own viewer if it has none.
//
// The insert is ON CONFLICT DO NOTHING on the primary key, so two televisions signing in