mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 15:06:37 +08:00
文件管理权限控制
This commit is contained in:
@@ -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))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user