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
+96
View File
@@ -3,12 +3,15 @@ package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/ponzischeme89/memby/server/internal/adminevents"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
@@ -41,6 +44,20 @@ type renameDeviceRequest struct {
DeviceName string `json:"deviceName"`
}
type recoveryRequest struct {
DeviceID string `json:"deviceId"`
ViewerID string `json:"viewerId"`
PIN string `json:"pin"`
}
type recoveryProfileResponse struct {
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
UserID string `json:"userId"`
Username string `json:"username"`
Viewer store.Viewer `json:"viewer"`
}
// handleLogin exchanges Emby credentials for a gateway token.
//
// The Emby access token stays here: the TV only ever holds the gateway token, so
@@ -227,6 +244,85 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
})
}
// handleRecoveryProfiles is intentionally unauthenticated: its only credential is the
// derived device id. It returns no upstream token or PIN material.
func (s *Server) handleRecoveryProfiles(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(r.URL.Query().Get("deviceId"))
if deviceID == "" {
writeError(w, http.StatusBadRequest, "device id is required")
return
}
profiles, err := s.store.DeviceRecoveryProfiles(r.Context(), deviceID)
if err != nil {
s.log.Error("device recovery lookup failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not check this device")
return
}
out := make([]recoveryProfileResponse, 0, len(profiles))
for _, p := range profiles {
out = append(out, recoveryProfileResponse{p.DeviceID, p.DeviceName, p.UserID, p.Username, p.Viewer})
}
writeJSON(w, http.StatusOK, map[string]any{"profiles": out})
}
// handleRecovery turns a valid profile PIN into a fresh ordinary gateway session. The
// upstream Emby token remains server-side, exactly like a password sign-in.
func (s *Server) handleRecovery(w http.ResponseWriter, r *http.Request) {
var req recoveryRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
profiles, err := s.store.DeviceRecoveryProfiles(r.Context(), strings.TrimSpace(req.DeviceID))
if err != nil {
writeError(w, http.StatusInternalServerError, "could not check this device")
return
}
var chosen *store.DeviceRecoveryProfile
for i := range profiles {
if profiles[i].Viewer.ID == strings.TrimSpace(req.ViewerID) {
chosen = &profiles[i]
break
}
}
if chosen == nil {
writeError(w, http.StatusUnauthorized, "profile recovery failed")
return
}
valid, pinErr := s.store.CheckViewerPIN(r.Context(), chosen.UserID, chosen.Viewer.ID, func(hash []byte) bool {
return bcrypt.CompareHashAndPassword(hash, []byte(req.PIN)) == nil
})
if pinErr != nil || !valid {
s.loggerFor(r.Context()).Warn("profile PIN rejected", "device_id", req.DeviceID, "viewer_id", req.ViewerID, "reason", pinReason(pinErr))
writeError(w, http.StatusUnauthorized, "incorrect PIN")
return
}
token, err := newToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "could not issue a token")
return
}
sess := store.Session{TokenHash: hashToken(token), EmbyUserID: chosen.UserID, EmbyToken: chosen.EmbyToken, Username: chosen.Username, ServerID: chosen.ServerID, DeviceID: chosen.DeviceID, DeviceName: chosen.DeviceName, ClientVersion: clientVersion(r), ClientProtocol: clientProtocol(r), ClientCapabilities: clientCapabilities(r)}
created, err := s.store.CreateSession(r.Context(), sess)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not start a session")
return
}
if len(created.ReplacedHash) > 0 {
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(created.ReplacedHash)))
}
s.cacheSession(r.Context(), sess)
s.recordLogin(r, store.LoginEvent{EmbyUserID: sess.EmbyUserID, Username: sess.Username, DeviceID: sess.DeviceID, DeviceName: sess.DeviceName, ClientVersion: sess.ClientVersion, Success: true, Method: store.LoginMethodPIN})
writeJSON(w, http.StatusOK, loginResponse{Token: token, UserID: sess.EmbyUserID, Username: sess.Username, ServerID: sess.ServerID})
}
func pinReason(err error) string {
if errors.Is(err, store.ErrPINLocked) {
return "temporarily locked"
}
return "invalid"
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess store.Session) {
if err := s.store.DeleteSession(r.Context(), sess.TokenHash); err != nil {
s.log.Error("session delete failed", "error", err)