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