文件管理权限控制

This commit is contained in:
ryan
2026-06-11 13:58:45 +08:00
parent 312bc7d4d5
commit 5e0de01c4b
18 changed files with 993 additions and 337 deletions
@@ -137,9 +137,9 @@ func TestListSystemConfigs(t *testing.T) {
var configs []model.SystemConfig
_ = json.Unmarshal(dataBytes, &configs)
// Defaults seed 23 configurations
if len(configs) != 23 {
t.Errorf("expected 23 default configs, got %d", len(configs))
// Defaults seed 24 configurations
if len(configs) != 24 {
t.Errorf("expected 24 default configs, got %d", len(configs))
}
})
+58 -44
View File
@@ -5,6 +5,7 @@
package oauth
import (
"errors"
"net/http"
"github.com/Rain-kl/Wavelet/internal/common"
@@ -26,6 +27,57 @@ type loginRequiredAuditLog struct {
Referer string `json:"referer"`
}
// GetUserFromRequest 校验 Access Token 或 Session 并返回用户对象,如果未登录或用户失效则返回 error
func GetUserFromRequest(c *gin.Context) (*model.User, error) {
ctx := c.Request.Context()
// check token in headers
tokenStr := c.GetHeader("X-Access-Token")
if tokenStr == "" {
authHeader := c.GetHeader("Authorization")
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
tokenStr = authHeader[7:]
}
}
var user model.User
var authenticated bool
var tokenAuth bool
var tokenAdmin bool
if tokenStr != "" {
tokenHash := model.HashToken(tokenStr)
var tokenRecord model.AccessToken
if err := db.DB(ctx).Where("token_hash = ?", tokenHash).First(&tokenRecord).Error; err == nil {
if err := db.DB(ctx).Where("id = ? AND is_active = ?", tokenRecord.UserID, true).First(&user).Error; err == nil {
authenticated = true
tokenAuth = true
tokenAdmin = tokenRecord.IsAdmin
}
}
}
if !authenticated {
// load user from session
userID := GetUserIDFromContext(c)
if userID <= 0 {
return nil, errors.New("unauthorized")
}
// load user from db to make sure is active
tx := db.DB(ctx).Where("id = ? AND is_active = ?", userID, true).First(&user)
if tx.Error != nil {
return nil, tx.Error
}
}
// set keys in context
util.SetToContext(c, TokenAuthKey, tokenAuth)
util.SetToContext(c, TokenAdminKey, tokenAdmin)
return &user, nil
}
// LoginRequired 返回登录鉴权中间件,校验 Access Token 或 Session
func LoginRequired() gin.HandlerFunc {
return func(c *gin.Context) {
@@ -33,55 +85,17 @@ func LoginRequired() gin.HandlerFunc {
ctx, span := otel_trace.Start(c.Request.Context(), "LoginRequired")
defer span.End()
// check token in headers
tokenStr := c.GetHeader("X-Access-Token")
if tokenStr == "" {
authHeader := c.GetHeader("Authorization")
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
tokenStr = authHeader[7:]
}
}
var user model.User
var authenticated bool
var tokenAuth bool
var tokenAdmin bool
if tokenStr != "" {
tokenHash := model.HashToken(tokenStr)
var tokenRecord model.AccessToken
if err := db.DB(ctx).Where("token_hash = ?", tokenHash).First(&tokenRecord).Error; err == nil {
if err := db.DB(ctx).Where("id = ? AND is_active = ?", tokenRecord.UserID, true).First(&user).Error; err == nil {
authenticated = true
tokenAuth = true
tokenAdmin = tokenRecord.IsAdmin
}
}
}
if !authenticated {
// load user from session
userID := GetUserIDFromContext(c)
if userID <= 0 {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil})
return
}
// load user from db to make sure is active
tx := db.DB(ctx).Where("id = ? AND is_active = ?", userID, true).First(&user)
if tx.Error != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil})
return
}
user, err := GetUserFromRequest(c)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil})
return
}
// log
LogForAudit(ctx, &user, c)
LogForAudit(ctx, user, c)
// set user info
util.SetToContext(c, UserObjKey, &user)
util.SetToContext(c, TokenAuthKey, tokenAuth)
util.SetToContext(c, TokenAdminKey, tokenAdmin)
util.SetToContext(c, UserObjKey, user)
// next
c.Next()
+48
View File
@@ -5,10 +5,14 @@
package upload
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/common"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/storage"
@@ -24,6 +28,7 @@ import (
// @Param id path string true "文件 ID"
// @Success 200 {file} file "成功获取文件内容"
// @Failure 400 {object} util.ResponseAny "文件 ID 格式错误"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 404 {object} util.ResponseAny "文件未找到"
// @Failure 500 {object} util.ResponseAny "服务内部错误"
// @Router /f/{id} [get]
@@ -50,6 +55,12 @@ func ServeFileByID(c *gin.Context) {
return
}
// 校验业务白名单与访问权限
if err := checkFileAccessPermission(c, upload.Type); err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil})
return
}
if upload.StorageDriver == "local" || (upload.StorageDriver == "" && !storage.IsEnabled()) {
c.File(upload.FilePath)
return
@@ -74,3 +85,40 @@ func ServeFileByID(c *gin.Context) {
// Respond with the file content
c.DataFromReader(http.StatusOK, obj.ContentLength, obj.ContentType, obj.Body, nil)
}
// checkFileAccessPermission 校验文件是否可以被当前请求访问
func checkFileAccessPermission(c *gin.Context, uploadType string) error {
var sc model.SystemConfig
var whitelist []string
if err := sc.GetByKey(c.Request.Context(), model.ConfigKeyFileAccessWhitelist); err == nil && sc.Value != "" {
if err := json.Unmarshal([]byte(sc.Value), &whitelist); err != nil {
// 降级使用逗号分隔解析
parts := strings.Split(sc.Value, ",")
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
whitelist = append(whitelist, p)
}
}
}
} else {
// 默认兜底白名单为 avatar
whitelist = []string{"avatar"}
}
inWhitelist := false
for _, w := range whitelist {
if strings.EqualFold(w, uploadType) {
inWhitelist = true
break
}
}
if !inWhitelist {
// 必须进行鉴权
if _, err := oauth.GetUserFromRequest(c); err != nil {
return err
}
}
return nil
}
+211
View File
@@ -0,0 +1,211 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package upload
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/Rain-kl/Wavelet/internal/common"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
)
func TestServeFileByIDAccessControl(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
// Ensure uploads dir is cleaned up
defer func() { _ = os.RemoveAll("uploads") }()
// Create a user in DB
user := model.User{
ID: 12345,
Username: "file_test_user",
IsActive: true,
}
if err := dbConn.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create an access token for this user
tokenStr := "test-secret-token-123"
tokenHash := model.HashToken(tokenStr)
tokenRecord := model.AccessToken{
UserID: user.ID,
Name: "test_token",
TokenHash: tokenHash,
}
if err := dbConn.Create(&tokenRecord).Error; err != nil {
t.Fatalf("failed to create token: %v", err)
}
// Create two files: one in whitelist (avatar), one not in whitelist (attachment)
avatarFile := model.Upload{
ID: 8001,
UserID: user.ID,
FileName: "avatar.png",
FilePath: "uploads/avatar.png",
FileSize: 5,
MimeType: "image/png",
Extension: "png",
StorageDriver: "local",
Type: "avatar",
Status: model.UploadStatusUsed,
}
attachmentFile := model.Upload{
ID: 8002,
UserID: user.ID,
FileName: "doc.pdf",
FilePath: "uploads/doc.pdf",
FileSize: 5,
MimeType: "application/pdf",
Extension: "pdf",
StorageDriver: "local",
Type: "attachment",
Status: model.UploadStatusUsed,
}
_ = os.MkdirAll("uploads", 0755)
_ = os.WriteFile(avatarFile.FilePath, []byte("image"), 0644)
_ = os.WriteFile(attachmentFile.FilePath, []byte("bytes"), 0644)
dbConn.Create(&avatarFile)
dbConn.Create(&attachmentFile)
// Set up router
gin.SetMode(gin.TestMode)
r := gin.New()
store := cookie.NewStore([]byte("secret"))
r.Use(sessions.Sessions("test_session", store))
r.GET("/f/:id", ServeFileByID)
t.Run("whitelisted file type (avatar) accessed without authentication", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/8001", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d. Body: %s", w.Code, w.Body.String())
}
if w.Body.String() != "image" {
t.Errorf("expected 'image', got %q", w.Body.String())
}
})
t.Run("non-whitelisted file type (attachment) accessed without authentication returns 401", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/8002", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d. Body: %s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("failed to parse JSON: %v", err)
}
if body["error_msg"] != common.UnAuthorized {
t.Errorf("expected error_msg %q, got %v", common.UnAuthorized, body["error_msg"])
}
})
t.Run("non-whitelisted file type (attachment) accessed with valid token succeeds", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/8002", nil)
req.Header.Set("X-Access-Token", tokenStr)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d. Body: %s", w.Code, w.Body.String())
}
if w.Body.String() != "bytes" {
t.Errorf("expected 'bytes', got %q", w.Body.String())
}
})
t.Run("accessing non-existent file returns 404", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/9999", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", w.Code)
}
})
}
func TestGetDistinctUploadTypes(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
// Seed some uploads with new custom types
user := model.User{ID: 2222, Username: "test_user_2"}
dbConn.Create(&user)
customUpload := model.Upload{
ID: 9001,
UserID: user.ID,
FileName: "custom.txt",
FilePath: "uploads/custom.txt",
FileSize: 10,
MimeType: "text/plain",
Extension: "txt",
StorageDriver: "local",
Type: "custom_type_xyz",
Status: model.UploadStatusUsed,
}
dbConn.Create(&customUpload)
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/api/v1/admin/uploads/types", GetDistinctUploadTypes)
req, _ := http.NewRequest("GET", "/api/v1/admin/uploads/types", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp struct {
ErrorMsg string `json:"error_msg"`
Data []string `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse JSON: %v", err)
}
if resp.ErrorMsg != "" {
t.Fatalf("unexpected error: %s", resp.ErrorMsg)
}
// Verify that custom_type_xyz and default types are present
hasCustom := false
hasAvatar := false
for _, typeName := range resp.Data {
if typeName == "custom_type_xyz" {
hasCustom = true
}
if typeName == "avatar" {
hasAvatar = true
}
}
if !hasCustom {
t.Errorf("expected custom_type_xyz to be in types, got: %v", resp.Data)
}
if !hasAvatar {
t.Errorf("expected avatar to be in types, got: %v", resp.Data)
}
}
+42
View File
@@ -19,6 +19,7 @@ import (
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -577,3 +578,44 @@ func saveUploadRecord(ctx context.Context, upload *model.Upload, storageDriver,
}
return ""
}
// DefaultUploadTypes 默认内置的文件业务类型
var DefaultUploadTypes = []string{"avatar", "attachment", "doc", "generic"}
// GetDistinctUploadTypes 获取所有已存在的文件业务类型及默认内置类型
// @Summary 获取文件业务类型列表
// @Description 返回系统中所有已上传文件所拥有的业务类型,并合并默认内置类型(avatar, attachment, doc, generic)
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} util.ResponseAny{data=[]string} "业务类型列表"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 500 {object} util.ResponseAny "内部错误"
// @Router /api/v1/admin/uploads/types [get]
func GetDistinctUploadTypes(c *gin.Context) {
var dbTypes []string
if err := db.DB(c.Request.Context()).Model(&model.Upload{}).Distinct().Pluck("type", &dbTypes).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 合并默认内置类型并去重
typeMap := make(map[string]bool)
for _, t := range DefaultUploadTypes {
typeMap[t] = true
}
for _, t := range dbTypes {
if t != "" {
typeMap[t] = true
}
}
result := make([]string, 0, len(typeMap))
for t := range typeMap {
result = append(result, t)
}
sort.Strings(result)
c.JSON(http.StatusOK, util.OK(result))
}
@@ -0,0 +1,7 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES ('file_access_whitelist', '["avatar"]', 'system', 1, '免登录访问的文件业务类型白名单 (JSON 数组格式)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'file_access_whitelist';
@@ -0,0 +1,7 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES ('file_access_whitelist', '["avatar"]', 'system', 1, '免登录访问的文件业务类型白名单 (JSON 数组格式)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'file_access_whitelist';
+2 -2
View File
@@ -38,8 +38,8 @@ func TestMigrateInitializesSQLiteDatabase(t *testing.T) {
if err := sqliteDB.Table("w_system_configs").Count(&systemConfigCount).Error; err != nil {
t.Fatalf("Migrate() count w_system_configs error = %v", err)
}
if systemConfigCount != 23 {
t.Errorf("Migrate() w_system_configs count = %d, want %d", systemConfigCount, 23)
if systemConfigCount != 24 {
t.Errorf("Migrate() w_system_configs count = %d, want %d", systemConfigCount, 24)
}
var adminCount int64
+1
View File
@@ -43,6 +43,7 @@ const (
ConfigKeyEmailRegisterVerificationEnabled = "email_register_verification_enabled" // 是否启用邮箱注册验证
ConfigKeyMenuDisplayConfig = "menu_display_config" // 目录显示配置 (JSON 字符串)
ConfigKeySearchEngineIndexingEnabled = "search_engine_indexing_enabled" // 是否允许搜索引擎检索
ConfigKeyFileAccessWhitelist = "file_access_whitelist" // 免登录访问的文件业务类型白名单 (JSON 数组格式)
)
const (
+3
View File
@@ -257,6 +257,9 @@ func registerRoutes(r *gin.Engine) {
adminRouter.PUT("/users/:id/status", admin_user.UpdateUserStatus)
adminRouter.DELETE("/users/:id", admin_user.DeleteUser)
// Uploads
adminRouter.GET("/uploads/types", upload.GetDistinctUploadTypes)
// System Config
adminRouter.POST("/system-configs", system_config.CreateSystemConfig)
adminRouter.GET("/system-configs", system_config.ListSystemConfigs)
+9
View File
@@ -37,6 +37,8 @@ func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func())
&model.Upload{},
&model.TaskExecution{},
&model.Template{},
&model.AccessToken{},
&model.Schedule{},
)
if err != nil {
t.Fatalf("failed to auto migrate tables: %v", err)
@@ -212,6 +214,12 @@ func seedDefaultConfigs(t *testing.T, tx *gorm.DB) {
Type: "system",
Description: "是否允许搜索引擎检索",
},
{
Key: model.ConfigKeyFileAccessWhitelist,
Value: `["avatar"]`,
Type: "system",
Description: "免登录访问的文件业务类型白名单",
},
}
if err := tx.Create(&defaultConfigs).Error; err != nil {
@@ -232,6 +240,7 @@ func seedDefaultConfigs(t *testing.T, tx *gorm.DB) {
model.ConfigKeyEmailRegisterVerificationEnabled: {},
model.ConfigKeyMenuDisplayConfig: {},
model.ConfigKeySearchEngineIndexingEnabled: {},
model.ConfigKeyFileAccessWhitelist: {},
}
keys := make([]string, 0, len(publicKeys))
for key := range publicKeys {