diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 3b35390..1840f1f 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -43,6 +43,7 @@ func createUserHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + refreshLicenseCapacityBestEffort(c.Request.Context(), svc) u, _, err := svc.Auth.Register(c.Request.Context(), req.Username, req.Password) if err != nil { writeUserMutationError(c, svc, err) diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 41547c5..19855db 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -62,6 +62,7 @@ func registerHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + refreshLicenseCapacityBestEffort(c.Request.Context(), svc) u, tokens, err := svc.Auth.Register(c.Request.Context(), req.Username, req.Password) if err != nil { if errors.Is(err, service.ErrUsernameTaken) { diff --git a/internal/handler/license.go b/internal/handler/license.go index 936a122..12d0bd7 100644 --- a/internal/handler/license.go +++ b/internal/handler/license.go @@ -29,7 +29,10 @@ const ( ) type licenseActivateReq struct { - Key string `json:"key" binding:"required"` + Key string `json:"key" binding:"required"` + // DeviceID is accepted for wire compatibility with older web clients but is + // intentionally ignored. Licensing binds to this MediaStationGo server + // instance, not to the browser that opened the admin page. DeviceID string `json:"device_id"` DeviceName string `json:"device_name"` } @@ -71,7 +74,7 @@ func licenseActivateHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - deviceID, err := ensureLicenseDeviceID(c.Request.Context(), svc, req.DeviceID) + deviceID, err := ensureLicenseDeviceID(c.Request.Context(), svc, "") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -162,6 +165,13 @@ func licenseHeartbeatHandler(svc *service.Container) gin.HandlerFunc { } } +func refreshLicenseCapacityBestEffort(ctx context.Context, svc *service.Container) { + if svc == nil || svc.Repo == nil || svc.Repo.Setting == nil { + return + } + _, _, _ = maybeSendLicenseHeartbeat(ctx, svc, 0) +} + // RunLicenseHeartbeatLoop keeps the license server aware of active deployments. // The loop checks periodically, but only sends when the last stored heartbeat is // older than licenseHeartbeatInterval. @@ -431,6 +441,7 @@ func licenseActivationView(state service.LicenseActivationState) gin.H { return gin.H{ "id": state.DeviceID, "key_id": state.LicenseType, + "key": maskLicenseKey(state.LicenseKey), "device_id": state.DeviceID, "device_name": state.DeviceName, "plan": state.LicenseType, @@ -444,6 +455,17 @@ func licenseActivationView(state service.LicenseActivationState) gin.H { } } +func maskLicenseKey(key string) string { + key = strings.TrimSpace(key) + if key == "" { + return "" + } + if len(key) <= 8 { + return key + } + return key[:5] + "..." + key[len(key)-4:] +} + func licenseStatusMessage(active bool, clientErr error) string { if active { return "已激活" diff --git a/internal/handler/license_client.go b/internal/handler/license_client.go index 5ecf5f9..1046659 100644 --- a/internal/handler/license_client.go +++ b/internal/handler/license_client.go @@ -36,9 +36,13 @@ func newLicenseClient(ctx context.Context, svc *service.Container) (*licenseClie if baseURL == "" { return nil, errors.New("license server url not configured") } + secret = strings.TrimSpace(secret) + if secret == "" { + return nil, errors.New("license hmac secret not configured") + } return &licenseClient{ baseURL: baseURL, - hmacSecret: strings.TrimSpace(secret), + hmacSecret: secret, httpClient: &http.Client{Timeout: 15 * time.Second}, }, nil } @@ -93,7 +97,10 @@ func (c *licenseClient) do(req *http.Request, out any) error { func (c *licenseClient) verifySigned(resp *licenseServerSignedResp) error { if c.hmacSecret == "" { - return nil + return errors.New("license hmac secret not configured") + } + if strings.TrimSpace(resp.Signature) == "" { + return errors.New("license server signature missing") } unsigned := struct { Valid bool `json:"valid"` diff --git a/internal/handler/license_test.go b/internal/handler/license_test.go index 8800cef..4f110bd 100644 --- a/internal/handler/license_test.go +++ b/internal/handler/license_test.go @@ -1,12 +1,23 @@ package handler import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" "github.com/ShukeBta/MediaStationGo/internal/service" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" ) func TestLicenseStatusMaxUsersUsesLicensedLimit(t *testing.T) { @@ -157,3 +168,108 @@ func TestLicenseHeartbeatEligibleRequiresActivationState(t *testing.T) { t.Fatalf("valid license state should trigger automatic license heartbeat") } } + +func TestLicenseActivateBindsServerInstanceNotBrowserFingerprint(t *testing.T) { + var upstreamFingerprint string + maxUsers := 60 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode upstream payload: %v", err) + } + upstreamFingerprint, _ = payload["fingerprint"].(string) + resp := licenseServerSignedResp{ + Valid: true, + LicenseType: "subscription", + MaxDevices: 3, + MaxUsers: &maxUsers, + NextHeartbeat: time.Now().Add(time.Hour).Format(time.RFC3339), + } + resp.Signature = signLicenseTestPayload("test-secret", resp) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer upstream.Close() + + svc := newLicenseHandlerTestService(t) + if err := svc.Repo.Setting.Set(t.Context(), licenseServerURLSetting, upstream.URL); err != nil { + t.Fatal(err) + } + if err := svc.Repo.Setting.Set(t.Context(), licenseHMACSecretSetting, "test-secret"); err != nil { + t.Fatal(err) + } + + router := gin.New() + router.POST("/license/activate", licenseActivateHandler(svc)) + req := httptest.NewRequest(http.MethodPost, "/license/activate", strings.NewReader(`{ + "key": "MS-ABCD-EFGH-JKLM-NPQR", + "device_id": "browser-fingerprint", + "device_name": "" + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("activate status = %d body=%s", w.Code, w.Body.String()) + } + if upstreamFingerprint == "" || upstreamFingerprint == "browser-fingerprint" || !strings.HasPrefix(upstreamFingerprint, "msgo-") { + t.Fatalf("activation should use server-generated msgo id, got %q", upstreamFingerprint) + } + stored, err := svc.Repo.Setting.Get(t.Context(), licenseDeviceIDSetting) + if err != nil { + t.Fatal(err) + } + if stored != upstreamFingerprint { + t.Fatalf("stored device id = %q, upstream fingerprint = %q", stored, upstreamFingerprint) + } +} + +func TestNewLicenseClientRequiresHMACSecret(t *testing.T) { + svc := newLicenseHandlerTestService(t) + if err := svc.Repo.Setting.Set(t.Context(), licenseServerURLSetting, "http://127.0.0.1:8001"); err != nil { + t.Fatal(err) + } + if _, err := newLicenseClient(t.Context(), svc); err == nil || !strings.Contains(err.Error(), "hmac secret") { + t.Fatalf("expected missing hmac secret error, got %v", err) + } +} + +func newLicenseHandlerTestService(t *testing.T) *service.Container { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Setting{}); err != nil { + t.Fatal(err) + } + return &service.Container{ + Cfg: &config.Config{}, + Repo: repository.New(db), + } +} + +func signLicenseTestPayload(secret string, resp licenseServerSignedResp) string { + unsigned := struct { + Valid bool `json:"valid"` + LicenseType string `json:"license_type"` + ExpiryDate *string `json:"expiry_date"` + MaxDevices int `json:"max_devices"` + MaxUsers *int `json:"max_users"` + DaysRemaining *int `json:"days_remaining"` + NextHeartbeat string `json:"next_heartbeat"` + }{ + Valid: resp.Valid, + LicenseType: resp.LicenseType, + ExpiryDate: resp.ExpiryDate, + MaxDevices: resp.MaxDevices, + MaxUsers: resp.MaxUsers, + DaysRemaining: resp.DaysRemaining, + NextHeartbeat: resp.NextHeartbeat, + } + payload, _ := json.Marshal(unsigned) + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write(payload) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/web/src/api/license.ts b/web/src/api/license.ts index 4e64e67..b475f35 100644 --- a/web/src/api/license.ts +++ b/web/src/api/license.ts @@ -33,13 +33,6 @@ export interface LicenseStatus { message?: string } -/** Generate a short device fingerprint from browser/OS info. */ -function deviceFingerprint(): string { - const nav = window.navigator - const parts = [nav.hardwareConcurrency, nav.language, screen.width, screen.height] - return btoa(parts.join('|')).slice(0, 32).replace(/[+/=]/g, '') -} - // ── API methods ── export const licenseAPI = { @@ -48,8 +41,6 @@ export const licenseAPI = { api .post('/license/activate', { key: key.trim(), - device_id: deviceFingerprint(), - device_name: navigator.platform || 'Web Client', }) .then((r) => r.data), diff --git a/web/src/pages/LicensePageSections.tsx b/web/src/pages/LicensePageSections.tsx index 1a369b3..4ddc0fb 100644 --- a/web/src/pages/LicensePageSections.tsx +++ b/web/src/pages/LicensePageSections.tsx @@ -18,7 +18,7 @@ export function LicenseHeader() {

许可证

-

绑定授权密钥以解锁全部功能

+

绑定授权密钥以提升多用户容量

) @@ -55,7 +55,7 @@ export function LicenseBindPanel({

- 输入从授权服务器获取的许可证密钥,激活后即可使用所有高级功能。 + 输入从授权服务器获取的许可证密钥,激活后按授权额度开放更多平台用户。

) @@ -126,7 +126,7 @@ export function LicenseStatusPanel({ {active && activation && (
- +