0.3.22 - PINs
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user