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:
2025-07-03 15:45:25 +12:00
parent b2b77eb4da
commit 8b6ba74ce9
19 changed files with 546 additions and 43 deletions

View File

@@ -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.)

View File

@@ -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)
})

View File

@@ -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

View File

@@ -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) {

View File

@@ -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
View 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
}