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
master
veypi 1 week ago
parent b8c894b5cf
commit b0322047cd

@ -28,7 +28,11 @@ func init() {
Router.Post("/bind", "绑定第三方账号", vigo.SkipBefore, bindThirdParty) Router.Post("/bind", "绑定第三方账号", vigo.SkipBefore, bindThirdParty)
Router.Post("/bind-register", "绑定并注册", vigo.SkipBefore, bindWithRegister) Router.Post("/bind-register", "绑定并注册", vigo.SkipBefore, bindWithRegister)
// === 当前用户(需要认证)=== // === 认证用户接口(需要登录)===
// 用户搜索(返回公开信息)
Router.Get("/users", "搜索用户", searchUsers)
// 当前用户(需要认证)
meRouter := Router.SubRouter("/me") meRouter := Router.SubRouter("/me")
meRouter.Get("/", "获取当前用户信息", me) meRouter.Get("/", "获取当前用户信息", me)
meRouter.Patch("/", "更新当前用户信息", updateMe) meRouter.Patch("/", "更新当前用户信息", updateMe)

@ -0,0 +1,74 @@
//
// Copyright (C) 2024 veypi <i@veypi.com>
// 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
}

@ -14,16 +14,16 @@ import (
var Router = vigo.NewRouter() var Router = vigo.NewRouter()
func init() { func init() {
// 管理员 管理用户权限 // 管理员 管理用户权限 (所有接口需要 user:admin 权限)
Router.Get("/", "用户列表", auth.VBaseAuth.Perm("user:read"), list) Router.Get("/", "用户列表", auth.VBaseAuth.Perm("user:admin"), list)
Router.Post("/", "创建用户", auth.VBaseAuth.Perm("user:admin"), create) Router.Post("/", "创建用户", auth.VBaseAuth.Perm("user:admin"), create)
Router.Get("/{user_id}", "获取用户详情", auth.VBaseAuth.Perm("user:read"), get) Router.Get("/{user_id}", "获取用户详情", auth.VBaseAuth.Perm("user:admin"), get)
Router.Patch("/{user_id}", "更新用户", patch) Router.Patch("/{user_id}", "更新用户", auth.VBaseAuth.Perm("user:admin"), patch)
Router.Delete("/{user_id}", "删除用户", auth.VBaseAuth.Perm("user:admin"), del) Router.Delete("/{user_id}", "删除用户", auth.VBaseAuth.Perm("user:admin"), del)
Router.Patch("/{user_id}/status", "更新用户状态", auth.VBaseAuth.Perm("user:admin"), updateStatus) 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.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) Router.Put("/{user_id}/permissions", "Update User Permissions", auth.VBaseAuth.Perm("user:admin"), updatePermissions)
} }

@ -38,7 +38,6 @@ func TestAuth(t *testing.T) {
// 2. Login Temp User // 2. Login Temp User
var tempToken string var tempToken string
var tempID string
t.Run("Login Temp User", func(t *testing.T) { t.Run("Login Temp User", func(t *testing.T) {
resp := doRequest(t, "POST", "/api/auth/login", map[string]string{ resp := doRequest(t, "POST", "/api/auth/login", map[string]string{
@ -64,12 +63,11 @@ func TestAuth(t *testing.T) {
var data UserResp var data UserResp
decodeResponse(t, resp, &data) 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) { 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", "nickname": "Temp Nickname",
}, tempToken) }, tempToken)
assertStatus(t, resp, 200) assertStatus(t, resp, 200)

@ -7,7 +7,7 @@ import (
func TestResourcePermission(t *testing.T) { func TestResourcePermission(t *testing.T) {
ensureUsers(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) { t.Run("Admin modifies User1", func(t *testing.T) {
resp := doRequest(t, "PATCH", "/api/users/"+User1ID, map[string]string{ resp := doRequest(t, "PATCH", "/api/users/"+User1ID, map[string]string{
"nickname": "Edited By Admin", "nickname": "Edited By Admin",
@ -21,9 +21,9 @@ func TestResourcePermission(t *testing.T) {
} }
}) })
// Case 2: User1 modifies User1 (Should Success) // Case 2: User1 modifies own info via /api/auth/me (Should Success)
t.Run("User1 modifies User1", func(t *testing.T) { t.Run("User1 modifies own info via /auth/me", func(t *testing.T) {
resp := doRequest(t, "PATCH", "/api/users/"+User1ID, map[string]string{ resp := doRequest(t, "PATCH", "/api/auth/me", map[string]string{
"nickname": "Edited By Self", "nickname": "Edited By Self",
}, User1Token) }, User1Token)
assertStatus(t, resp, 200) assertStatus(t, resp, 200)
@ -35,41 +35,27 @@ func TestResourcePermission(t *testing.T) {
} }
}) })
// Case 3: User1 modifies User2 (Should Fail 403/404) // Case 3: User1 modifies User2 via /api/users (Should Fail - admin only now)
t.Run("User1 modifies User2", func(t *testing.T) { t.Run("User1 modifies User2 via /api/users", func(t *testing.T) {
resp := doRequest(t, "PATCH", "/api/users/"+User2ID, map[string]string{ resp := doRequest(t, "PATCH", "/api/users/"+User2ID, map[string]string{
"nickname": "Hacked By User1", "nickname": "Hacked By User1",
}, User1Token) }, User1Token)
// Expecting 403 Forbidden or 404 NotFound // Should fail with 403 Forbidden (admin-only endpoint)
if resp.Code != 200 { if resp.Code != 403 {
// Good t.Errorf("Expected 403 Forbidden, got %d. Body: %s", resp.Code, resp.Body.String())
} 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)
}
} }
}) })
// Case 4: User1 modifies Admin (Should Fail 403/404) // Case 4: User1 modifies Admin via /api/users (Should Fail - admin only now)
t.Run("User1 modifies Admin", func(t *testing.T) { t.Run("User1 modifies Admin via /api/users", func(t *testing.T) {
resp := doRequest(t, "PATCH", "/api/users/"+AdminID, map[string]string{ resp := doRequest(t, "PATCH", "/api/users/"+AdminID, map[string]string{
"nickname": "Hacked By User1", "nickname": "Hacked By User1",
}, User1Token) }, User1Token)
if resp.Code != 200 { // Should fail with 403 Forbidden (admin-only endpoint)
// Good if resp.Code != 403 {
} else { t.Errorf("Expected 403 Forbidden, got %d. Body: %s", resp.Code, resp.Body.String())
var errResp BaseResp
decodeResponse(t, resp, &errResp)
if errResp.Code < 40000 {
t.Errorf("Expected error code, got %d. Msg: %s", errResp.Code, errResp.Msg)
}
} }
}) })
} }

@ -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)
})
}
Loading…
Cancel
Save