diff --git a/server/app/goauto/device/handler.go b/server/app/goauto/device/handler.go index 5bbd2cf..fe9a25b 100644 --- a/server/app/goauto/device/handler.go +++ b/server/app/goauto/device/handler.go @@ -47,19 +47,24 @@ func (handler Handler) List(context *gin.Context) { } func currentUserID(c *gin.Context) uint64 { - value, ok := c.Get("userId") - if !ok { - return 0 - } - switch id := value.(type) { + // #333: go-admin's Authorizator runs on every request with the + // IdentityHandler map, which has no "user" entry, so c.Get("userId") is + // always 0 there. The JWT "identity" claim is the authenticated user id. + switch id := jwt.ExtractClaims(c)["identity"].(type) { + case float64: + if id > 0 { + return uint64(id) + } case int: - return uint64(id) + if id > 0 { + return uint64(id) + } case int64: - return uint64(id) + if id > 0 { + return uint64(id) + } case uint64: return id - case float64: - return uint64(id) } return 0 } diff --git a/server/app/goauto/device/list_owner_handler_test.go b/server/app/goauto/device/list_owner_handler_test.go new file mode 100644 index 0000000..2bd19c8 --- /dev/null +++ b/server/app/goauto/device/list_owner_handler_test.go @@ -0,0 +1,84 @@ +package device + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "go-admin/app/goauto/models" +) + +// #333: mirrors the real go-admin middleware, whose Authorizator sets +// userId to 0 on every request while the JWT claims carry the identity. +func listDevicesAs(t *testing.T, handler Handler, claims jwt.MapClaims) []DeviceListItem { + t.Helper() + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.GET("/devices", func(c *gin.Context) { + c.Set(jwt.JwtPayloadKey, claims) + c.Set("userId", 0) + c.Next() + }, handler.List) + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/devices?page=1&pageSize=20", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + var body struct { + Data DeviceListResponse `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + return body.Data.Items +} + +func seedOwnedDevice(t *testing.T, handler Handler, name string, owner *uint64) models.AgentDevice { + t.Helper() + device := models.AgentDevice{ + InstallID: "install-" + name, Name: name, Manufacturer: "test", Model: "test", + AndroidVersion: "14", AgentVersion: "1", PDDVersion: "1", CapabilitiesJSON: "[]", + Status: models.DeviceStatusOnline, TokenDigest: fmt.Sprintf("digest-%s", name), + TokenIssuedAt: time.Now(), OwnerUserID: owner, + } + if err := handler.DB.Create(&device).Error; err != nil { + t.Fatalf("create device: %v", err) + } + return device +} + +func deviceNames(items []DeviceListItem) map[string]bool { + names := map[string]bool{} + for _, item := range items { + names[item.Name] = true + } + return names +} + +func TestDeviceListPurchaserSeesOnlyOwnDevicesFromJWTIdentity(t *testing.T) { + handler := Handler{DB: openTestDatabase(t)} + two, three := uint64(2), uint64(3) + seedOwnedDevice(t, handler, "caigou1-phone", &two) + seedOwnedDevice(t, handler, "caigou2-phone", &three) + seedOwnedDevice(t, handler, "unowned-phone", nil) + + names := deviceNames(listDevicesAs(t, handler, jwt.MapClaims{"rolekey": "purchaser", "identity": float64(3)})) + if len(names) != 1 || !names["caigou2-phone"] { + t.Fatalf("purchaser 3 should see only own device, got %v", names) + } + + all := deviceNames(listDevicesAs(t, handler, jwt.MapClaims{"rolekey": "admin", "identity": float64(1)})) + if len(all) != 3 { + t.Fatalf("admin should see all devices, got %v", all) + } + + none := listDevicesAs(t, handler, jwt.MapClaims{"rolekey": "purchaser"}) + if len(none) != 0 { + t.Fatalf("purchaser without identity must see no devices, got %d", len(none)) + } +} diff --git a/server/app/goauto/shopeeproduct/current_user_test.go b/server/app/goauto/shopeeproduct/current_user_test.go new file mode 100644 index 0000000..92b0d3d --- /dev/null +++ b/server/app/goauto/shopeeproduct/current_user_test.go @@ -0,0 +1,31 @@ +package shopeeproduct + +import ( + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" +) + +// #333: the real middleware sets userId=0 on every request; the operator used +// for owned-device collection lookup and audit must come from the JWT identity. +func TestCurrentUserIDReadsJWTIdentityNotUserIDKey(t *testing.T) { + gin.SetMode(gin.TestMode) + cases := []struct { + claims jwt.MapClaims + want uint64 + }{ + {jwt.MapClaims{"rolekey": "purchaser", "identity": float64(3)}, 3}, + {jwt.MapClaims{"rolekey": "purchaser"}, 0}, + {jwt.MapClaims{"rolekey": "purchaser", "identity": float64(-1)}, 0}, + } + for _, tc := range cases { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set(jwt.JwtPayloadKey, tc.claims) + c.Set("userId", 0) + if got := currentUserID(c); got != tc.want { + t.Fatalf("claims %v: got %d want %d", tc.claims, got, tc.want) + } + } +} diff --git a/server/app/goauto/shopeeproduct/handler.go b/server/app/goauto/shopeeproduct/handler.go index 1b0b87e..b70179b 100644 --- a/server/app/goauto/shopeeproduct/handler.go +++ b/server/app/goauto/shopeeproduct/handler.go @@ -427,22 +427,26 @@ func (handler Handler) service(c *gin.Context) (*Service, bool) { // purchasing roles per #40; role membership itself is enforced by the router // middleware, not here. func currentUserID(c *gin.Context) uint64 { - value, exists := c.Get("userId") - if !exists { - return 0 - } - switch id := value.(type) { + // #333: go-admin's Authorizator runs on every request with the + // IdentityHandler map, which has no "user" entry, so c.Get("userId") is + // always 0 there. The JWT "identity" claim is the authenticated user id. + switch id := jwt.ExtractClaims(c)["identity"].(type) { + case float64: + if id > 0 { + return uint64(id) + } case int: - return uint64(id) + if id > 0 { + return uint64(id) + } case int64: - return uint64(id) + if id > 0 { + return uint64(id) + } case uint64: return id - case float64: - return uint64(id) - default: - return 0 } + return 0 } func currentRole(c *gin.Context) string {