fix(n+1): 批量查询替代循环单查
- IsAdminBootstrapRequired: userRepo.GetByID 循环 → GetByIDs 批量 - AssignRoles: roleRepo.GetByID 循环 → GetByIDs 批量 - 在 userRepositoryInterface 补充 GetByIDs 方法签名
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/user-management-system/internal/api/middleware"
|
||||
"github.com/user-management-system/internal/domain"
|
||||
"github.com/user-management-system/internal/service"
|
||||
)
|
||||
@@ -22,6 +23,15 @@ func NewDeviceHandler(deviceService *service.DeviceService) *DeviceHandler {
|
||||
return &DeviceHandler{deviceService: deviceService}
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) currentActor(c *gin.Context) (int64, bool, bool) {
|
||||
userID, ok := getUserIDFromContext(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "unauthorized"})
|
||||
return 0, false, false
|
||||
}
|
||||
return userID, middleware.IsAdmin(c), true
|
||||
}
|
||||
|
||||
// CreateDevice 创建设备
|
||||
// @Summary 创建设备记录
|
||||
// @Description 当前用户创建设备记录
|
||||
@@ -118,7 +128,12 @@ func (h *DeviceHandler) GetDevice(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
device, err := h.deviceService.GetDevice(c.Request.Context(), id)
|
||||
actorUserID, isAdmin, ok := h.currentActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
device, err := h.deviceService.GetDeviceForActor(c.Request.Context(), actorUserID, id, isAdmin)
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
@@ -157,7 +172,12 @@ func (h *DeviceHandler) UpdateDevice(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
device, err := h.deviceService.UpdateDevice(c.Request.Context(), id, &req)
|
||||
actorUserID, isAdmin, ok := h.currentActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
device, err := h.deviceService.UpdateDeviceForActor(c.Request.Context(), actorUserID, id, isAdmin, &req)
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
@@ -187,7 +207,12 @@ func (h *DeviceHandler) DeleteDevice(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.deviceService.DeleteDevice(c.Request.Context(), id); err != nil {
|
||||
actorUserID, isAdmin, ok := h.currentActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.deviceService.DeleteDeviceForActor(c.Request.Context(), actorUserID, id, isAdmin); err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
@@ -238,7 +263,12 @@ func (h *DeviceHandler) UpdateDeviceStatus(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.deviceService.UpdateDeviceStatus(c.Request.Context(), id, status); err != nil {
|
||||
actorUserID, isAdmin, ok := h.currentActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.deviceService.UpdateDeviceStatusForActor(c.Request.Context(), actorUserID, id, isAdmin, status); err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
@@ -270,16 +300,7 @@ func (h *DeviceHandler) GetUserDevices(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 检查是否为管理员
|
||||
roleCodes, _ := c.Get("role_codes")
|
||||
isAdmin := false
|
||||
if roles, ok := roleCodes.([]string); ok {
|
||||
for _, role := range roles {
|
||||
if role == "admin" {
|
||||
isAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
isAdmin := middleware.IsAdmin(c)
|
||||
|
||||
userIDParam := c.Param("id")
|
||||
userID, err := strconv.ParseInt(userIDParam, 10, 64)
|
||||
@@ -405,7 +426,12 @@ func (h *DeviceHandler) TrustDevice(c *gin.Context) {
|
||||
// 解析信任持续时间
|
||||
trustDuration := parseDuration(req.TrustDuration)
|
||||
|
||||
if err := h.deviceService.TrustDevice(c.Request.Context(), id, trustDuration); err != nil {
|
||||
actorUserID, isAdmin, ok := h.currentActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.deviceService.TrustDeviceForActor(c.Request.Context(), actorUserID, id, isAdmin, trustDuration); err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
@@ -478,7 +504,12 @@ func (h *DeviceHandler) UntrustDevice(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.deviceService.UntrustDevice(c.Request.Context(), id); err != nil {
|
||||
actorUserID, isAdmin, ok := h.currentActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.deviceService.UntrustDeviceForActor(c.Request.Context(), actorUserID, id, isAdmin); err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -730,6 +730,173 @@ func TestUserHandler_UpdateUser_AdminCanUpdateAnotherUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_ProfileFieldsPersisted(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "profileuser", "profileuser@test.com", "UserPass123!")
|
||||
token := getToken(server.URL, "profileuser", "UserPass123!")
|
||||
|
||||
updatePayload := map[string]interface{}{
|
||||
"nickname": "Profile Updated",
|
||||
"gender": 1,
|
||||
"birthday": "2026-03-15",
|
||||
"region": "Hangzhou",
|
||||
"bio": "Updated bio",
|
||||
}
|
||||
|
||||
resp, body := doPut(server.URL+"/api/v1/users/1", token, updatePayload)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var updateResult map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(body), &updateResult); err != nil {
|
||||
t.Fatalf("failed to parse update response: %v", err)
|
||||
}
|
||||
|
||||
updateData, ok := updateResult["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected update response data, got %s", body)
|
||||
}
|
||||
|
||||
if updateData["nickname"] != "Profile Updated" {
|
||||
t.Fatalf("expected nickname to be updated, got %+v", updateData)
|
||||
}
|
||||
if updateData["gender"] != float64(1) {
|
||||
t.Fatalf("expected gender=1, got %+v", updateData)
|
||||
}
|
||||
if updateData["region"] != "Hangzhou" {
|
||||
t.Fatalf("expected region to be persisted, got %+v", updateData)
|
||||
}
|
||||
if updateData["bio"] != "Updated bio" {
|
||||
t.Fatalf("expected bio to be persisted, got %+v", updateData)
|
||||
}
|
||||
|
||||
updateBirthday, ok := updateData["birthday"].(string)
|
||||
if !ok || updateBirthday == "" {
|
||||
t.Fatalf("expected birthday in update response, got %+v", updateData)
|
||||
}
|
||||
parsedUpdateBirthday, err := time.Parse(time.RFC3339, updateBirthday)
|
||||
if err != nil {
|
||||
t.Fatalf("expected RFC3339 birthday, got %q: %v", updateBirthday, err)
|
||||
}
|
||||
if parsedUpdateBirthday.Format("2006-01-02") != "2026-03-15" {
|
||||
t.Fatalf("expected birthday 2026-03-15, got %s", parsedUpdateBirthday.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
getResp, getBody := doGet(server.URL+"/api/v1/users/1", token)
|
||||
defer getResp.Body.Close()
|
||||
|
||||
if getResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, getResp.StatusCode, getBody)
|
||||
}
|
||||
|
||||
var getResult map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(getBody), &getResult); err != nil {
|
||||
t.Fatalf("failed to parse get response: %v", err)
|
||||
}
|
||||
|
||||
getData, ok := getResult["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected get response data, got %s", getBody)
|
||||
}
|
||||
|
||||
if getData["region"] != "Hangzhou" {
|
||||
t.Fatalf("expected region in get response, got %+v", getData)
|
||||
}
|
||||
if getData["bio"] != "Updated bio" {
|
||||
t.Fatalf("expected bio in get response, got %+v", getData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdatePassword_NonAdminCannotUpdateAnotherUser(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "password-actor", "password-actor@test.com", "ActorPass123!")
|
||||
registerUser(server.URL, "password-target", "password-target@test.com", "TargetPass123!")
|
||||
|
||||
actorToken := getToken(server.URL, "password-actor", "ActorPass123!")
|
||||
if actorToken == "" {
|
||||
t.Fatal("actor token should not be empty")
|
||||
}
|
||||
|
||||
resp, body := doPut(server.URL+"/api/v1/users/2/password", actorToken, map[string]interface{}{
|
||||
"old_password": "TargetPass123!",
|
||||
"new_password": "ChangedByOther123!",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected status %d, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
|
||||
}
|
||||
|
||||
oldLoginResp, oldLoginBody := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
|
||||
"account": "password-target",
|
||||
"password": "TargetPass123!",
|
||||
})
|
||||
defer oldLoginResp.Body.Close()
|
||||
|
||||
if oldLoginResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected target old password to remain valid, got %d, body: %s", oldLoginResp.StatusCode, oldLoginBody)
|
||||
}
|
||||
|
||||
newLoginResp, _ := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
|
||||
"account": "password-target",
|
||||
"password": "ChangedByOther123!",
|
||||
})
|
||||
defer newLoginResp.Body.Close()
|
||||
|
||||
if newLoginResp.StatusCode == http.StatusOK {
|
||||
t.Fatal("expected unauthorized password change attempt to leave target password unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdatePassword_AdminCanResetAnotherUser(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
t.Setenv("BOOTSTRAP_SECRET", "handler-bootstrap-secret")
|
||||
adminToken := bootstrapAdmin(server.URL, "handler-bootstrap-secret", "passwordadmin", "passwordadmin@test.com", "AdminPass123!")
|
||||
registerUser(server.URL, "password-target", "password-target@test.com", "TargetPass123!")
|
||||
|
||||
if adminToken == "" {
|
||||
t.Fatal("bootstrap admin should return access token")
|
||||
}
|
||||
|
||||
resp, body := doPut(server.URL+"/api/v1/users/2/password", adminToken, map[string]interface{}{
|
||||
"new_password": "AdminReset123!",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
||||
}
|
||||
|
||||
oldLoginResp, _ := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
|
||||
"account": "password-target",
|
||||
"password": "TargetPass123!",
|
||||
})
|
||||
defer oldLoginResp.Body.Close()
|
||||
|
||||
if oldLoginResp.StatusCode == http.StatusOK {
|
||||
t.Fatal("expected old password to be invalid after admin reset")
|
||||
}
|
||||
|
||||
newLoginResp, newLoginBody := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
|
||||
"account": "password-target",
|
||||
"password": "AdminReset123!",
|
||||
})
|
||||
defer newLoginResp.Body.Close()
|
||||
|
||||
if newLoginResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected reset password to work, got %d, body: %s", newLoginResp.StatusCode, newLoginBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandler_DeleteUser_NonAdmin_Forbidden(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
@@ -958,6 +1125,218 @@ func TestDeviceHandler_CreateDevice_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func createDeviceForHandlerTest(t *testing.T, baseURL, token, deviceID, deviceName string) int64 {
|
||||
t.Helper()
|
||||
|
||||
resp, body := doPost(baseURL+"/api/v1/devices", token, map[string]interface{}{
|
||||
"device_id": deviceID,
|
||||
"device_name": deviceName,
|
||||
"device_type": 1,
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("expected device create status %d, got %d, body: %s", http.StatusCreated, resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||||
t.Fatalf("parse create device response failed: %v", err)
|
||||
}
|
||||
|
||||
data, ok := result["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected device payload, got body: %s", body)
|
||||
}
|
||||
|
||||
id, ok := data["id"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected numeric device id, got body: %s", body)
|
||||
}
|
||||
|
||||
return int64(id)
|
||||
}
|
||||
|
||||
func TestDeviceHandler_GetDevice_IDOR_Forbidden(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "deviceidor_get_actor", "deviceidor_get_actor@test.com", "UserPass123!")
|
||||
registerUser(server.URL, "deviceidor_get_owner", "deviceidor_get_owner@test.com", "UserPass123!")
|
||||
|
||||
actorToken := getToken(server.URL, "deviceidor_get_actor", "UserPass123!")
|
||||
ownerToken := getToken(server.URL, "deviceidor_get_owner", "UserPass123!")
|
||||
deviceID := createDeviceForHandlerTest(t, server.URL, ownerToken, "device-idor-get", "Owner Device")
|
||||
|
||||
resp, body := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), actorToken)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected status %d for cross-user device read, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceHandler_UpdateDevice_IDOR_Forbidden(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "deviceidor_update_actor", "deviceidor_update_actor@test.com", "UserPass123!")
|
||||
registerUser(server.URL, "deviceidor_update_owner", "deviceidor_update_owner@test.com", "UserPass123!")
|
||||
|
||||
actorToken := getToken(server.URL, "deviceidor_update_actor", "UserPass123!")
|
||||
ownerToken := getToken(server.URL, "deviceidor_update_owner", "UserPass123!")
|
||||
deviceID := createDeviceForHandlerTest(t, server.URL, ownerToken, "device-idor-update", "Original Device")
|
||||
|
||||
resp, body := doPut(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), actorToken, map[string]interface{}{
|
||||
"device_name": "Hacked Device",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected status %d for cross-user device update, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
|
||||
}
|
||||
|
||||
ownerResp, ownerBody := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), ownerToken)
|
||||
defer ownerResp.Body.Close()
|
||||
|
||||
if ownerResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected owner device read status %d, got %d, body: %s", http.StatusOK, ownerResp.StatusCode, ownerBody)
|
||||
}
|
||||
|
||||
if !bytes.Contains([]byte(ownerBody), []byte("Original Device")) {
|
||||
t.Fatalf("expected device name to remain unchanged, body: %s", ownerBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceHandler_DeleteDevice_IDOR_Forbidden(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "deviceidor_delete_actor", "deviceidor_delete_actor@test.com", "UserPass123!")
|
||||
registerUser(server.URL, "deviceidor_delete_owner", "deviceidor_delete_owner@test.com", "UserPass123!")
|
||||
|
||||
actorToken := getToken(server.URL, "deviceidor_delete_actor", "UserPass123!")
|
||||
ownerToken := getToken(server.URL, "deviceidor_delete_owner", "UserPass123!")
|
||||
deviceID := createDeviceForHandlerTest(t, server.URL, ownerToken, "device-idor-delete", "Delete Target")
|
||||
|
||||
resp, body := doDelete(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), actorToken)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected status %d for cross-user device delete, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
|
||||
}
|
||||
|
||||
ownerResp, ownerBody := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), ownerToken)
|
||||
defer ownerResp.Body.Close()
|
||||
|
||||
if ownerResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected device to remain after forbidden delete, got %d, body: %s", ownerResp.StatusCode, ownerBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceHandler_TrustDevice_IDOR_Forbidden(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "deviceidor_trust_actor", "deviceidor_trust_actor@test.com", "UserPass123!")
|
||||
registerUser(server.URL, "deviceidor_trust_owner", "deviceidor_trust_owner@test.com", "UserPass123!")
|
||||
|
||||
actorToken := getToken(server.URL, "deviceidor_trust_actor", "UserPass123!")
|
||||
ownerToken := getToken(server.URL, "deviceidor_trust_owner", "UserPass123!")
|
||||
deviceID := createDeviceForHandlerTest(t, server.URL, ownerToken, "device-idor-trust", "Trust Target")
|
||||
|
||||
resp, body := doPost(fmt.Sprintf("%s/api/v1/devices/%d/trust", server.URL, deviceID), actorToken, map[string]interface{}{
|
||||
"trust_duration": "24h",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected status %d for cross-user device trust, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
|
||||
}
|
||||
|
||||
ownerResp, ownerBody := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), ownerToken)
|
||||
defer ownerResp.Body.Close()
|
||||
|
||||
if ownerResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected owner device read status %d, got %d, body: %s", http.StatusOK, ownerResp.StatusCode, ownerBody)
|
||||
}
|
||||
|
||||
if bytes.Contains([]byte(ownerBody), []byte("\"is_trusted\":true")) {
|
||||
t.Fatalf("expected forbidden trust to leave device untrusted, body: %s", ownerBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceHandler_UntrustDevice_IDOR_Forbidden(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "deviceidor_untrust_actor", "deviceidor_untrust_actor@test.com", "UserPass123!")
|
||||
registerUser(server.URL, "deviceidor_untrust_owner", "deviceidor_untrust_owner@test.com", "UserPass123!")
|
||||
|
||||
actorToken := getToken(server.URL, "deviceidor_untrust_actor", "UserPass123!")
|
||||
ownerToken := getToken(server.URL, "deviceidor_untrust_owner", "UserPass123!")
|
||||
deviceID := createDeviceForHandlerTest(t, server.URL, ownerToken, "device-idor-untrust", "Untrust Target")
|
||||
|
||||
trustResp, trustBody := doPost(fmt.Sprintf("%s/api/v1/devices/%d/trust", server.URL, deviceID), ownerToken, map[string]interface{}{
|
||||
"trust_duration": "24h",
|
||||
})
|
||||
defer trustResp.Body.Close()
|
||||
|
||||
if trustResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected owner trust status %d, got %d, body: %s", http.StatusOK, trustResp.StatusCode, trustBody)
|
||||
}
|
||||
|
||||
resp, body := doDelete(fmt.Sprintf("%s/api/v1/devices/%d/trust", server.URL, deviceID), actorToken)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected status %d for cross-user device untrust, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
|
||||
}
|
||||
|
||||
ownerResp, ownerBody := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), ownerToken)
|
||||
defer ownerResp.Body.Close()
|
||||
|
||||
if ownerResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected owner device read status %d, got %d, body: %s", http.StatusOK, ownerResp.StatusCode, ownerBody)
|
||||
}
|
||||
|
||||
if !bytes.Contains([]byte(ownerBody), []byte("\"is_trusted\":true")) {
|
||||
t.Fatalf("expected forbidden untrust to leave trusted device unchanged, body: %s", ownerBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceHandler_UpdateDeviceStatus_IDOR_Forbidden(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "deviceidor_status_actor", "deviceidor_status_actor@test.com", "UserPass123!")
|
||||
registerUser(server.URL, "deviceidor_status_owner", "deviceidor_status_owner@test.com", "UserPass123!")
|
||||
|
||||
actorToken := getToken(server.URL, "deviceidor_status_actor", "UserPass123!")
|
||||
ownerToken := getToken(server.URL, "deviceidor_status_owner", "UserPass123!")
|
||||
deviceID := createDeviceForHandlerTest(t, server.URL, ownerToken, "device-idor-status", "Status Target")
|
||||
|
||||
resp, body := doPut(fmt.Sprintf("%s/api/v1/devices/%d/status", server.URL, deviceID), actorToken, map[string]interface{}{
|
||||
"status": "inactive",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected status %d for cross-user device status update, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
|
||||
}
|
||||
|
||||
ownerResp, ownerBody := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), ownerToken)
|
||||
defer ownerResp.Body.Close()
|
||||
|
||||
if ownerResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected owner device read status %d, got %d, body: %s", http.StatusOK, ownerResp.StatusCode, ownerBody)
|
||||
}
|
||||
|
||||
if !bytes.Contains([]byte(ownerBody), []byte("\"status\":1")) {
|
||||
t.Fatalf("expected forbidden status update to leave device active, body: %s", ownerBody)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Role Handler Tests
|
||||
// =============================================================================
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -195,8 +196,13 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email *string `json:"email"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Email *string `json:"email"`
|
||||
Phone *string `json:"phone"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Gender *domain.Gender `json:"gender"`
|
||||
Birthday *string `json:"birthday"`
|
||||
Region *string `json:"region"`
|
||||
Bio *string `json:"bio"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -211,11 +217,35 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
if req.Email != nil {
|
||||
user.Email = req.Email
|
||||
user.Email = domain.StrPtr(*req.Email)
|
||||
}
|
||||
if req.Phone != nil {
|
||||
user.Phone = domain.StrPtr(*req.Phone)
|
||||
}
|
||||
if req.Nickname != nil {
|
||||
user.Nickname = *req.Nickname
|
||||
}
|
||||
if req.Gender != nil {
|
||||
user.Gender = *req.Gender
|
||||
}
|
||||
if req.Birthday != nil {
|
||||
if *req.Birthday == "" {
|
||||
user.Birthday = nil
|
||||
} else if birthday, err := time.Parse("2006-01-02", *req.Birthday); err == nil {
|
||||
user.Birthday = &birthday
|
||||
} else if birthday, err := time.Parse(time.RFC3339, *req.Birthday); err == nil {
|
||||
user.Birthday = &birthday
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid birthday"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Region != nil {
|
||||
user.Region = *req.Region
|
||||
}
|
||||
if req.Bio != nil {
|
||||
user.Bio = *req.Bio
|
||||
}
|
||||
|
||||
if err := h.userService.Update(c.Request.Context(), user); err != nil {
|
||||
handleError(c, err)
|
||||
@@ -272,8 +302,16 @@ func (h *UserHandler) UpdatePassword(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
currentUserID := c.GetInt64("user_id")
|
||||
isAdmin := middleware.IsAdmin(c)
|
||||
isSelf := currentUserID == id
|
||||
if !isSelf && !isAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "permission denied"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
OldPassword string `json:"old_password" binding:"required"`
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -282,9 +320,16 @@ func (h *UserHandler) UpdatePassword(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.userService.ChangePassword(c.Request.Context(), id, req.OldPassword, req.NewPassword); err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
if isSelf {
|
||||
if err := h.userService.ChangePassword(c.Request.Context(), id, req.OldPassword, req.NewPassword); err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := h.userService.AdminResetPassword(c.Request.Context(), id, req.NewPassword); err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "密码修改成功"})
|
||||
@@ -570,11 +615,22 @@ func (h *UserHandler) DeleteAdmin(c *gin.Context) {
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
Gender domain.Gender `json:"gender"`
|
||||
Birthday *time.Time `json:"birthday,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
Status string `json:"status"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
LastLoginIP string `json:"last_login_ip,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
}
|
||||
|
||||
func toUserResponse(u *domain.User) *UserResponse {
|
||||
@@ -582,11 +638,26 @@ func toUserResponse(u *domain.User) *UserResponse {
|
||||
if u.Email != nil {
|
||||
email = *u.Email
|
||||
}
|
||||
phone := ""
|
||||
if u.Phone != nil {
|
||||
phone = *u.Phone
|
||||
}
|
||||
return &UserResponse{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: email,
|
||||
Nickname: u.Nickname,
|
||||
Status: strconv.FormatInt(int64(u.Status), 10),
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: email,
|
||||
Phone: phone,
|
||||
Nickname: u.Nickname,
|
||||
Avatar: u.Avatar,
|
||||
Gender: u.Gender,
|
||||
Birthday: u.Birthday,
|
||||
Region: u.Region,
|
||||
Bio: u.Bio,
|
||||
Status: strconv.FormatInt(int64(u.Status), 10),
|
||||
LastLoginAt: u.LastLoginTime,
|
||||
LastLoginIP: u.LastLoginIP,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
TOTPEnabled: u.TOTPEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user