From b0322047cd373ea6a66817fc68ed70fbd319b5e5 Mon Sep 17 00:00:00 2001 From: veypi Date: Tue, 17 Feb 2026 18:23:03 +0800 Subject: [PATCH] feat: Restrict user APIs to admins and add public user search - Add /api/auth/users endpoint for authenticated users to search other users - Only return public info (id, username, nickname, avatar) in search results - Change /api/user routes to require user:admin permission instead of user:read - Update auth tests to use /api/auth/me for self updates - Add tests for new user search endpoint --- api/auth/init.go | 6 ++- api/auth/search_users.go | 74 ++++++++++++++++++++++++++++++ api/user/init.go | 12 ++--- tests/auth_test.go | 6 +-- tests/resource_perm_test.go | 42 ++++++----------- tests/search_users_test.go | 89 +++++++++++++++++++++++++++++++++++++ 6 files changed, 190 insertions(+), 39 deletions(-) create mode 100644 api/auth/search_users.go create mode 100644 tests/search_users_test.go diff --git a/api/auth/init.go b/api/auth/init.go index 4177a65..9733acc 100644 --- a/api/auth/init.go +++ b/api/auth/init.go @@ -28,7 +28,11 @@ func init() { Router.Post("/bind", "绑定第三方账号", vigo.SkipBefore, bindThirdParty) Router.Post("/bind-register", "绑定并注册", vigo.SkipBefore, bindWithRegister) - // === 当前用户(需要认证)=== + // === 认证用户接口(需要登录)=== + // 用户搜索(返回公开信息) + Router.Get("/users", "搜索用户", searchUsers) + + // 当前用户(需要认证) meRouter := Router.SubRouter("/me") meRouter.Get("/", "获取当前用户信息", me) meRouter.Patch("/", "更新当前用户信息", updateMe) diff --git a/api/auth/search_users.go b/api/auth/search_users.go new file mode 100644 index 0000000..672fe5e --- /dev/null +++ b/api/auth/search_users.go @@ -0,0 +1,74 @@ +// +// Copyright (C) 2024 veypi +// 2025-03-04 16:08:06 +// Distributed under terms of the MIT license. +// + +package auth + +import ( + "github.com/veypi/vbase/cfg" + "github.com/veypi/vbase/models" + "github.com/veypi/vigo" +) + +// PublicUserInfo 公开的用户信息(仅包含无关紧要的信息) +type PublicUserInfo struct { + ID string `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` +} + +// SearchUsersRequest 搜索用户请求 +type SearchUsersRequest struct { + Keyword *string `json:"keyword" src:"query" desc:"搜索关键词(用户名或昵称)"` + Limit int `json:"limit" src:"query" default:"20" desc:"返回数量限制"` +} + +// SearchUsersResponse 搜索用户响应 +type SearchUsersResponse struct { + Items []PublicUserInfo `json:"items"` + Total int64 `json:"total"` +} + +// searchUsers 搜索用户(公开接口,仅返回公开信息) +func searchUsers(x *vigo.X, req *SearchUsersRequest) (*SearchUsersResponse, error) { + if req.Limit <= 0 || req.Limit > 50 { + req.Limit = 20 + } + + db := cfg.DB().Model(&models.User{}).Where("status = ?", models.UserStatusActive) + + // 搜索关键词 + if req.Keyword != nil && *req.Keyword != "" { + keyword := "%" + *req.Keyword + "%" + db = db.Where("username LIKE ? OR nickname LIKE ?", keyword, keyword) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + var users []models.User + if err := db.Order("created_at DESC").Limit(req.Limit).Find(&users).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + // 转换为公开信息 + items := make([]PublicUserInfo, len(users)) + for i, user := range users { + items[i] = PublicUserInfo{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Avatar: user.Avatar, + } + } + + return &SearchUsersResponse{ + Items: items, + Total: total, + }, nil +} diff --git a/api/user/init.go b/api/user/init.go index 1df2ad6..9755c00 100644 --- a/api/user/init.go +++ b/api/user/init.go @@ -14,16 +14,16 @@ import ( var Router = vigo.NewRouter() func init() { - // 管理员 管理用户权限 - Router.Get("/", "用户列表", auth.VBaseAuth.Perm("user:read"), list) + // 管理员 管理用户权限 (所有接口需要 user:admin 权限) + Router.Get("/", "用户列表", auth.VBaseAuth.Perm("user:admin"), list) Router.Post("/", "创建用户", auth.VBaseAuth.Perm("user:admin"), create) - Router.Get("/{user_id}", "获取用户详情", auth.VBaseAuth.Perm("user:read"), get) - Router.Patch("/{user_id}", "更新用户", patch) + Router.Get("/{user_id}", "获取用户详情", auth.VBaseAuth.Perm("user:admin"), get) + Router.Patch("/{user_id}", "更新用户", auth.VBaseAuth.Perm("user:admin"), patch) Router.Delete("/{user_id}", "删除用户", auth.VBaseAuth.Perm("user:admin"), del) Router.Patch("/{user_id}/status", "更新用户状态", auth.VBaseAuth.Perm("user:admin"), updateStatus) - Router.Get("/{user_id}/roles", "Get User Roles", auth.VBaseAuth.Perm("user:read"), getRoles) + Router.Get("/{user_id}/roles", "Get User Roles", auth.VBaseAuth.Perm("user:admin"), getRoles) Router.Put("/{user_id}/roles", "Update User Roles", auth.VBaseAuth.Perm("user:admin"), updateRoles) - Router.Get("/{user_id}/permissions", "Get User Permissions", auth.VBaseAuth.Perm("user:read"), getPermissions) + Router.Get("/{user_id}/permissions", "Get User Permissions", auth.VBaseAuth.Perm("user:admin"), getPermissions) Router.Put("/{user_id}/permissions", "Update User Permissions", auth.VBaseAuth.Perm("user:admin"), updatePermissions) } diff --git a/tests/auth_test.go b/tests/auth_test.go index 2202800..63d6240 100644 --- a/tests/auth_test.go +++ b/tests/auth_test.go @@ -38,7 +38,6 @@ func TestAuth(t *testing.T) { // 2. Login Temp User var tempToken string - var tempID string t.Run("Login Temp User", func(t *testing.T) { resp := doRequest(t, "POST", "/api/auth/login", map[string]string{ @@ -64,12 +63,11 @@ func TestAuth(t *testing.T) { var data UserResp decodeResponse(t, resp, &data) - tempID = data.ID }) - // 4. Update User Info + // 4. Update User Info (using /api/auth/me for regular users) t.Run("Update Temp User Info", func(t *testing.T) { - resp := doRequest(t, "PATCH", "/api/users/"+tempID, map[string]string{ + resp := doRequest(t, "PATCH", "/api/auth/me", map[string]string{ "nickname": "Temp Nickname", }, tempToken) assertStatus(t, resp, 200) diff --git a/tests/resource_perm_test.go b/tests/resource_perm_test.go index d7ddbbf..59c296d 100644 --- a/tests/resource_perm_test.go +++ b/tests/resource_perm_test.go @@ -7,7 +7,7 @@ import ( func TestResourcePermission(t *testing.T) { ensureUsers(t) - // Case 1: Admin modifies User1 (Should Success) + // Case 1: Admin modifies User1 (Should Success - admin-only endpoint) t.Run("Admin modifies User1", func(t *testing.T) { resp := doRequest(t, "PATCH", "/api/users/"+User1ID, map[string]string{ "nickname": "Edited By Admin", @@ -21,9 +21,9 @@ func TestResourcePermission(t *testing.T) { } }) - // Case 2: User1 modifies User1 (Should Success) - t.Run("User1 modifies User1", func(t *testing.T) { - resp := doRequest(t, "PATCH", "/api/users/"+User1ID, map[string]string{ + // Case 2: User1 modifies own info via /api/auth/me (Should Success) + t.Run("User1 modifies own info via /auth/me", func(t *testing.T) { + resp := doRequest(t, "PATCH", "/api/auth/me", map[string]string{ "nickname": "Edited By Self", }, User1Token) assertStatus(t, resp, 200) @@ -35,41 +35,27 @@ func TestResourcePermission(t *testing.T) { } }) - // Case 3: User1 modifies User2 (Should Fail 403/404) - t.Run("User1 modifies User2", func(t *testing.T) { + // Case 3: User1 modifies User2 via /api/users (Should Fail - admin only now) + t.Run("User1 modifies User2 via /api/users", func(t *testing.T) { resp := doRequest(t, "PATCH", "/api/users/"+User2ID, map[string]string{ "nickname": "Hacked By User1", }, User1Token) - // Expecting 403 Forbidden or 404 NotFound - if resp.Code != 200 { - // Good - } else { - // Check Vigo code - var errResp BaseResp - decodeResponse(t, resp, &errResp) - // Common Forbidden/NotFound codes: 40300, 40400, etc. - // Or maybe 40100 Unauthorized - if errResp.Code < 40000 { - t.Errorf("Expected error code, got %d. Msg: %s", errResp.Code, errResp.Msg) - } + // Should fail with 403 Forbidden (admin-only endpoint) + if resp.Code != 403 { + t.Errorf("Expected 403 Forbidden, got %d. Body: %s", resp.Code, resp.Body.String()) } }) - // Case 4: User1 modifies Admin (Should Fail 403/404) - t.Run("User1 modifies Admin", func(t *testing.T) { + // Case 4: User1 modifies Admin via /api/users (Should Fail - admin only now) + t.Run("User1 modifies Admin via /api/users", func(t *testing.T) { resp := doRequest(t, "PATCH", "/api/users/"+AdminID, map[string]string{ "nickname": "Hacked By User1", }, User1Token) - if resp.Code != 200 { - // Good - } else { - var errResp BaseResp - decodeResponse(t, resp, &errResp) - if errResp.Code < 40000 { - t.Errorf("Expected error code, got %d. Msg: %s", errResp.Code, errResp.Msg) - } + // Should fail with 403 Forbidden (admin-only endpoint) + if resp.Code != 403 { + t.Errorf("Expected 403 Forbidden, got %d. Body: %s", resp.Code, resp.Body.String()) } }) } diff --git a/tests/search_users_test.go b/tests/search_users_test.go new file mode 100644 index 0000000..020af43 --- /dev/null +++ b/tests/search_users_test.go @@ -0,0 +1,89 @@ +package tests + +import ( + "testing" +) + +// PublicUserInfo 公开用户信息响应 +type PublicUserInfo struct { + ID string `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` +} + +// SearchUsersResp 搜索用户响应 +type SearchUsersResp struct { + Items []PublicUserInfo `json:"items"` + Total int64 `json:"total"` +} + +func TestSearchUsers(t *testing.T) { + // Ensure base users are created (Admin, User1, User2) + ensureUsers(t) + + // Test 1: Search users without auth (should fail) + t.Run("Search Users Without Auth", func(t *testing.T) { + resp := doRequest(t, "GET", "/api/auth/users", nil, "") + // Should return 401 unauthorized + assertStatus(t, resp, 401) + }) + + // Test 2: Search users with auth + t.Run("Search Users With Auth", func(t *testing.T) { + resp := doRequest(t, "GET", "/api/auth/users", nil, User1Token) + assertStatus(t, resp, 200) + + var data SearchUsersResp + decodeResponse(t, resp, &data) + t.Logf("Search users response: total=%d, items=%d", data.Total, len(data.Items)) + + // Should return users + if data.Total <= 0 { + t.Errorf("Expected some users, got total=%d", data.Total) + } + }) + + // Test 3: Search users with keyword + t.Run("Search Users With Keyword", func(t *testing.T) { + resp := doRequest(t, "GET", "/api/auth/users?keyword=user1", nil, User1Token) + assertStatus(t, resp, 200) + + var data SearchUsersResp + decodeResponse(t, resp, &data) + t.Logf("Search users with keyword: total=%d, items=%d", data.Total, len(data.Items)) + }) + + // Test 4: Verify public info is returned (only id, username, nickname, avatar) + t.Run("Verify Public Info Only", func(t *testing.T) { + resp := doRequest(t, "GET", "/api/auth/users?limit=1", nil, User1Token) + assertStatus(t, resp, 200) + + var data SearchUsersResp + decodeResponse(t, resp, &data) + + if len(data.Items) > 0 { + user := data.Items[0] + // Should have these fields + if user.ID == "" { + t.Error("Expected id to be present") + } + if user.Username == "" { + t.Error("Expected username to be present") + } + // Nickname and avatar can be empty but field should exist + t.Logf("User public info: id=%s, username=%s, nickname=%s, avatar=%s", + user.ID, user.Username, user.Nickname, user.Avatar) + } + }) + + // Test 5: Admin can search users too + t.Run("Admin Can Search Users", func(t *testing.T) { + resp := doRequest(t, "GET", "/api/auth/users", nil, AdminToken) + assertStatus(t, resp, 200) + + var data SearchUsersResp + decodeResponse(t, resp, &data) + t.Logf("Admin search users: total=%d", data.Total) + }) +}