You've already forked openaccounting-server
forked from cybercinch/openaccounting-server
feat: implement secure file upload system with JWT authentication
- Add JWT-based secure file access for local storage with 1-hour expiry - Implement GORM repository methods for attachment CRUD operations - Add secure file serving endpoint with token validation - Update storage interface to support user context in URL generation - Add comprehensive security features including path traversal protection - Update documentation with security model and configuration examples - Add utility functions for hex/byte conversion and UUID validation - Configure secure file permissions (0600) for uploaded files 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,9 @@ type Storage interface {
|
||||
// GetURL returns a URL for accessing the file (may be signed/temporary)
|
||||
GetURL(path string, expiry time.Duration) (string, error)
|
||||
|
||||
// GetURLWithContext returns a URL for accessing the file with user context (for JWT tokens)
|
||||
GetURLWithContext(path string, expiry time.Duration, userID, orgID string) (string, error)
|
||||
|
||||
// Exists checks if a file exists at the given path
|
||||
Exists(path string) (bool, error)
|
||||
|
||||
@@ -53,6 +56,9 @@ type LocalConfig struct {
|
||||
|
||||
// Base URL for serving files (optional)
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
|
||||
// Signing key for JWT tokens (optional, will be auto-generated if empty)
|
||||
SigningKey string `mapstructure:"signing_key"`
|
||||
}
|
||||
|
||||
// S3Config configures S3-compatible storage (AWS S3, Backblaze B2, Cloudflare R2, etc.)
|
||||
|
||||
@@ -54,19 +54,13 @@ func TestNewStorage(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("B2 Storage", func(t *testing.T) {
|
||||
t.Run("Invalid Backend", func(t *testing.T) {
|
||||
config := Config{
|
||||
Backend: "b2",
|
||||
B2: B2Config{
|
||||
AccountID: "test-account",
|
||||
ApplicationKey: "test-key",
|
||||
Bucket: "test-bucket",
|
||||
},
|
||||
Backend: "invalid",
|
||||
}
|
||||
|
||||
// This will fail because we don't have real B2 credentials
|
||||
storage, err := NewStorage(config)
|
||||
assert.Error(t, err) // Expected to fail without credentials
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, storage)
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package storage
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -13,8 +14,9 @@ import (
|
||||
|
||||
// LocalStorage implements the Storage interface for local filesystem
|
||||
type LocalStorage struct {
|
||||
rootDir string
|
||||
baseURL string
|
||||
rootDir string
|
||||
baseURL string
|
||||
tokenService *TokenService
|
||||
}
|
||||
|
||||
// NewLocalStorage creates a new local filesystem storage backend
|
||||
@@ -24,14 +26,18 @@ func NewLocalStorage(config LocalConfig) (*LocalStorage, error) {
|
||||
rootDir = "./uploads"
|
||||
}
|
||||
|
||||
// Ensure the root directory exists
|
||||
if err := os.MkdirAll(rootDir, 0755); err != nil {
|
||||
// Ensure the root directory exists with secure permissions
|
||||
if err := os.MkdirAll(rootDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create storage directory: %w", err)
|
||||
}
|
||||
|
||||
// Initialize token service for secure URL generation
|
||||
tokenService := NewTokenService(config.SigningKey)
|
||||
|
||||
return &LocalStorage{
|
||||
rootDir: rootDir,
|
||||
baseURL: config.BaseURL,
|
||||
rootDir: rootDir,
|
||||
baseURL: config.BaseURL,
|
||||
tokenService: tokenService,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -41,14 +47,14 @@ func (l *LocalStorage) Store(filename string, content io.Reader, contentType str
|
||||
storagePath := l.generateStoragePath(filename)
|
||||
fullPath := filepath.Join(l.rootDir, storagePath)
|
||||
|
||||
// Ensure the directory exists
|
||||
// Ensure the directory exists with secure permissions
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return "", fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
// Create and write the file
|
||||
file, err := os.Create(fullPath)
|
||||
// Create and write the file with secure permissions
|
||||
file, err := os.OpenFile(fullPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
@@ -102,8 +108,13 @@ func (l *LocalStorage) Delete(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetURL returns a URL for accessing the file
|
||||
// GetURL returns a secure URL for accessing the file with JWT token
|
||||
func (l *LocalStorage) GetURL(path string, expiry time.Duration) (string, error) {
|
||||
return l.GetURLWithContext(path, expiry, "", "")
|
||||
}
|
||||
|
||||
// GetURLWithContext returns a secure URL for accessing the file with JWT token and user context
|
||||
func (l *LocalStorage) GetURLWithContext(path string, expiry time.Duration, userID, orgID string) (string, error) {
|
||||
// Validate path to prevent directory traversal
|
||||
if err := l.validatePath(path); err != nil {
|
||||
return "", err
|
||||
@@ -118,14 +129,16 @@ func (l *LocalStorage) GetURL(path string, expiry time.Duration) (string, error)
|
||||
return "", &FileNotFoundError{Path: path}
|
||||
}
|
||||
|
||||
if l.baseURL != "" {
|
||||
// Return a public URL if base URL is configured
|
||||
return l.baseURL + "/" + path, nil
|
||||
// Generate secure JWT token for file access
|
||||
token, err := l.tokenService.GenerateFileToken(path, userID, orgID, expiry)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate access token: %w", err)
|
||||
}
|
||||
|
||||
// For local storage without a base URL, return the file path
|
||||
// In a real application, you might serve these through an endpoint
|
||||
return "/files/" + path, nil
|
||||
// Return secure URL with token parameter
|
||||
params := url.Values{}
|
||||
params.Set("token", token)
|
||||
return "/secure-files?" + params.Encode(), nil
|
||||
}
|
||||
|
||||
// Exists checks if a file exists at the given path
|
||||
|
||||
@@ -72,8 +72,11 @@ func TestLocalStorage(t *testing.T) {
|
||||
|
||||
url, err := storage.GetURL(path, time.Hour)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, url, path)
|
||||
assert.Contains(t, url, config.BaseURL)
|
||||
// New JWT token-based URLs should start with /secure-files and contain a token parameter
|
||||
assert.Contains(t, url, "/secure-files")
|
||||
assert.Contains(t, url, "token=")
|
||||
// The token should be a JWT (contains dots for header.payload.signature)
|
||||
assert.Contains(t, url, ".")
|
||||
})
|
||||
|
||||
t.Run("Delete File", func(t *testing.T) {
|
||||
|
||||
@@ -155,6 +155,12 @@ func (s *S3Storage) GetURL(path string, expiry time.Duration) (string, error) {
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// GetURLWithContext returns a presigned URL for accessing the file (S3 doesn't use user context)
|
||||
func (s *S3Storage) GetURLWithContext(path string, expiry time.Duration, userID, orgID string) (string, error) {
|
||||
// For S3, user context is not needed as presigned URLs are cryptographically secure
|
||||
return s.GetURL(path, expiry)
|
||||
}
|
||||
|
||||
// Exists checks if a file exists in S3
|
||||
func (s *S3Storage) Exists(path string) (bool, error) {
|
||||
input := &s3.HeadObjectInput{
|
||||
|
||||
83
core/storage/token.go
Normal file
83
core/storage/token.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/openaccounting/oa-server/core/util/id"
|
||||
)
|
||||
|
||||
// TokenService handles JWT token generation and validation for file access
|
||||
type TokenService struct {
|
||||
signingKey []byte
|
||||
}
|
||||
|
||||
// NewTokenService creates a new token service with a signing key
|
||||
func NewTokenService(signingKey string) *TokenService {
|
||||
if signingKey == "" {
|
||||
// Generate a random signing key if none provided
|
||||
// In production, this should be a consistent secret from config
|
||||
signingKey = id.String(id.New())
|
||||
}
|
||||
|
||||
return &TokenService{
|
||||
signingKey: []byte(signingKey),
|
||||
}
|
||||
}
|
||||
|
||||
// FileClaims represents the JWT claims for file access
|
||||
type FileClaims struct {
|
||||
FilePath string `json:"file_path"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
OrgID string `json:"org_id,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateFileToken creates a JWT token for accessing a specific file
|
||||
func (ts *TokenService) GenerateFileToken(filePath string, userID, orgID string, expiry time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
claims := FileClaims{
|
||||
FilePath: filePath,
|
||||
UserID: userID,
|
||||
OrgID: orgID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(expiry)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
Issuer: "openaccounting-server",
|
||||
Subject: "file-access",
|
||||
ID: id.String(id.New()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString(ts.signingKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign token: %w", err)
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
// ValidateFileToken validates a JWT token and returns the file claims
|
||||
func (ts *TokenService) ValidateFileToken(tokenString string) (*FileClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &FileClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
// Verify the signing method
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return ts.signingKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token: %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*FileClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
Reference in New Issue
Block a user