diff --git a/internal/config/config.go b/internal/config/config.go index cff91c74..e18c8c99 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,13 +9,15 @@ import ( ) type Config struct { - FileStorageURL string - DB DBConfig - API APIConfig - Broker BrokerConfig - CORS CORSConfig - JWTSecretKey string - Dump bool + FileStorageURL string + DB DBConfig + API APIConfig + Broker BrokerConfig + CORS CORSConfig + JWTSecretKey string + Dump bool + SignedURLTTLSeconds uint16 + SignedURLSecretKey string } type DBConfig struct { @@ -55,12 +57,13 @@ type BrokerConfig struct { } const ( - defaultAPIPort = "8080" - defaultAPIRefreshTokenPath = "/api/v1/auth/refresh" - defaultQueueName = "worker_queue" - defaultResponseQueueName = "worker_response_queue" - defaultCORSAllowedOrigins = "http://localhost:3000,http://localhost:5173" - defaultAccessTokenMinutesStr = "180" + defaultAPIPort = "8080" + defaultAPIRefreshTokenPath = "/api/v1/auth/refresh" + defaultQueueName = "worker_queue" + defaultResponseQueueName = "worker_response_queue" + defaultCORSAllowedOrigins = "http://localhost:3000,http://localhost:5173" + defaultAccessTokenMinutesStr = "180" + defaultSignedURLTTLSecondsStr = "300" // 5 minutes ) // NewConfig creates new Config instance @@ -97,6 +100,10 @@ const ( // // - JWT_ACCESS_TOKEN_MINUTES - access token lifetime in minutes. Default is 180 // +// - SIGNED_URL_TTL_SECONDS - time-to-live for signed file URLs in seconds. Default is 300 (5 minutes) +// +// - SIGNED_URL_SECRET_KEY - secret key for signing file URLs. Default is JWT_SECRET_KEY if not set +// // - LANGUAGES - comma-separated list of languages with their version, // e.g. "c:99,c:11,c:18,cpp:11,cpp:14,cpp:17,cpp:20,cpp:23". Default will expand to [DefaultLanguages] func NewConfig() *Config { @@ -209,6 +216,23 @@ func NewConfig() *Config { More info: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/Errors/CORSNotSupportingCredentials`) } + signedURLTTLSecondsStr := os.Getenv("SIGNED_URL_TTL_SECONDS") + if signedURLTTLSecondsStr == "" { + log.Warnf("SIGNED_URL_TTL_SECONDS is not set. Using default value %s", defaultSignedURLTTLSecondsStr) + signedURLTTLSecondsStr = defaultSignedURLTTLSecondsStr + } + signedURLTTLSecondsParsed, err := strconv.ParseUint(signedURLTTLSecondsStr, 10, 16) + if err != nil { + log.Panicf("invalid SIGNED_URL_TTL_SECONDS value %s", signedURLTTLSecondsStr) + } + signedURLTTLSeconds := uint16(signedURLTTLSecondsParsed) + + signedURLSecretKey := os.Getenv("SIGNED_URL_SECRET_KEY") + if signedURLSecretKey == "" { + log.Warnf("SIGNED_URL_SECRET_KEY is not set. Using JWT_SECRET_KEY as fallback") + signedURLSecretKey = jwtSecretKey + } + return &Config{ DB: DBConfig{ Host: dbHost, @@ -234,9 +258,11 @@ More info: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/Errors/CORSNot AllowedOrigins: corsAllowedOrigins, AllowCredentials: corsAllowCredentials, }, - FileStorageURL: fileStorageURL, - JWTSecretKey: jwtSecretKey, - Dump: dump, + FileStorageURL: fileStorageURL, + JWTSecretKey: jwtSecretKey, + Dump: dump, + SignedURLTTLSeconds: signedURLTTLSeconds, + SignedURLSecretKey: signedURLSecretKey, } } diff --git a/internal/initialization/initialization.go b/internal/initialization/initialization.go index 0a055501..16294c34 100644 --- a/internal/initialization/initialization.go +++ b/internal/initialization/initialization.go @@ -63,7 +63,11 @@ func NewInitialization(cfg *config.Config) *Initialization { accessControlRepository := repository.NewAccessControlRepository() // Services - filestorage, err := filestorage.NewFileStorageService(cfg.FileStorageURL) + filestorage, err := filestorage.NewFileStorageService( + cfg.FileStorageURL, + cfg.SignedURLSecretKey, + cfg.SignedURLTTLSeconds, + ) if err != nil { log.Panicf("Failed to create file storage service: %s", err.Error()) } diff --git a/package/filestorage/mock_filestorage.go b/package/filestorage/mock_filestorage.go index fe55fed9..d32d621f 100644 --- a/package/filestorage/mock_filestorage.go +++ b/package/filestorage/mock_filestorage.go @@ -53,6 +53,21 @@ func (mr *MockFileStorageServiceMockRecorder) GetFileURL(path any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFileURL", reflect.TypeOf((*MockFileStorageService)(nil).GetFileURL), path) } +// GetSignedFileURL mocks base method. +func (m *MockFileStorageService) GetSignedFileURL(path string, ttlSeconds uint16) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSignedFileURL", path, ttlSeconds) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSignedFileURL indicates an expected call of GetSignedFileURL. +func (mr *MockFileStorageServiceMockRecorder) GetSignedFileURL(path, ttlSeconds any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSignedFileURL", reflect.TypeOf((*MockFileStorageService)(nil).GetSignedFileURL), path, ttlSeconds) +} + // GetTestResultDiffPath mocks base method. func (m *MockFileStorageService) GetTestResultDiffPath(taskID, userID int64, submissionOrder, testCaseOrder int) *UploadedFile { m.ctrl.T.Helper() diff --git a/package/filestorage/service.go b/package/filestorage/service.go index 6fc42d09..ba50a2e4 100644 --- a/package/filestorage/service.go +++ b/package/filestorage/service.go @@ -44,9 +44,13 @@ type FileStorageService interface { UploadSolutionFile(taskID, userID int64, newOrder int, filePath string) (*UploadedFile, error) - // + // GetFileURL returns the direct URL to access a file (no signature, no expiration) GetFileURL(path string) string + // GetSignedFileURL generates a signed URL with expiration for the given file path. + // The ttlSeconds parameter specifies how long the URL should be valid. + GetSignedFileURL(path string, ttlSeconds uint16) (string, error) + GetTestResultStdoutPath(taskID, userID int64, submissionOrder, testCaseOrder int) *UploadedFile GetTestResultStderrPath(taskID, userID int64, submissionOrder, testCaseOrder int) *UploadedFile GetTestResultDiffPath(taskID, userID int64, submissionOrder, testCaseOrder int) *UploadedFile @@ -313,14 +317,15 @@ func (d *decompressor) decompressZip(archivePath string, newPath string) error { } type fileStorageService struct { - decompressor Decompressor - validator ArchiveValidator - storage filestorage.FileStorage - bucketName string - logger *zap.SugaredLogger + decompressor Decompressor + validator ArchiveValidator + storage filestorage.FileStorage + bucketName string + signedURLGenerator *utils.SignedURLGenerator + logger *zap.SugaredLogger } -func NewFileStorageService(fileStorageURL string) (FileStorageService, error) { +func NewFileStorageService(fileStorageURL string, signedURLSecretKey string, signedURLTTLSeconds uint16) (FileStorageService, error) { validator := NewArchiveValidator() // Configure validation rules @@ -352,12 +357,16 @@ func NewFileStorageService(fileStorageURL string) (FileStorageService, error) { if err != nil { return nil, fmt.Errorf("failed to create file storage: %w", err) } + + signedURLGenerator := utils.NewSignedURLGenerator(signedURLSecretKey, signedURLTTLSeconds) + return &fileStorageService{ - decompressor: &decompressor{}, - validator: validator, - storage: storage, - bucketName: "maxit", - logger: utils.NewNamedLogger("file-storage"), + decompressor: &decompressor{}, + validator: validator, + storage: storage, + bucketName: "maxit", + signedURLGenerator: signedURLGenerator, + logger: utils.NewNamedLogger("file-storage"), }, nil } @@ -556,6 +565,11 @@ func (f *fileStorageService) GetFileURL(path string) string { return f.storage.GetFileURL(f.bucketName, path) } +func (f *fileStorageService) GetSignedFileURL(path string, ttlSeconds uint16) (string, error) { + baseURL := f.storage.GetFileURL(f.bucketName, path) + return f.signedURLGenerator.GenerateSignedURLWithTTL(baseURL, ttlSeconds) +} + func (f *fileStorageService) UploadSolutionFile(taskID, userID int64, order int, filePath string) (*UploadedFile, error) { file, err := os.Open(filePath) if err != nil { diff --git a/package/service/submission_service_test.go b/package/service/submission_service_test.go index a71915ed..7ffd1279 100644 --- a/package/service/submission_service_test.go +++ b/package/service/submission_service_test.go @@ -57,7 +57,7 @@ func setupSubmissionServiceTest(t *testing.T) *testSetup { userService := mock_service.NewMockUserService(ctrl) queueService := mock_service.NewMockQueueService(ctrl) acs := mock_service.NewMockAccessControlService(ctrl) - fs, err := filestorage.NewFileStorageService("dummy") + fs, err := filestorage.NewFileStorageService("dummy", "test-secret", 300) require.NoError(t, err) svc := service.NewSubmissionService( diff --git a/package/service/task_service.go b/package/service/task_service.go index fb92b779..c88ec1de 100644 --- a/package/service/task_service.go +++ b/package/service/task_service.go @@ -141,7 +141,7 @@ func (ts *taskService) GetAll(db database.Database, _ *schemas.User, paginationP return result, nil } -func (ts *taskService) Get(db database.Database, _ *schemas.User, taskID int64) (*schemas.TaskDetailed, error) { +func (ts *taskService) Get(db database.Database, currentUser *schemas.User, taskID int64) (*schemas.TaskDetailed, error) { // Get the task task, err := ts.taskRepository.Get(db, taskID) if err != nil { @@ -152,28 +152,19 @@ func (ts *taskService) Get(db database.Database, _ *schemas.User, taskID int64) return nil, err } - // switch types.UserRole(currentUser.Role) { - // case types.UserRoleStudent: - // // Check if the task is assigned to the user - // isAssigned, err := ts.taskRepository.IsTaskAssignedToUser(db, taskID, currentUser.ID) - // if err != nil { - // ts.logger.Errorf("Error checking if task is assigned to user: %v", err.Error()) - // return nil, err - // } - // if !isAssigned { - // return nil, errors.ErrNotAuthorized - // } - // case types.UserRoleTeacher: - // // Check if the task is created by the user - // if task.CreatedBy != currentUser.ID { - // return nil, errors.ErrNotAuthorized - // } - // } + // Generate signed URL for the description file + // Authorization is enforced by the route handler, so if we reach here, user is authorized + // Use a fixed TTL from config for simplicity and security + descriptionURL, err := ts.filestorage.GetSignedFileURL(task.DescriptionFile.Path, 300) + if err != nil { + ts.logger.Errorf("Error generating signed URL: %v", err.Error()) + return nil, fmt.Errorf("failed to generate signed URL: %w", err) + } result := &schemas.TaskDetailed{ ID: task.ID, Title: task.Title, - DescriptionURL: ts.filestorage.GetFileURL(task.DescriptionFile.Path), + DescriptionURL: descriptionURL, CreatedBy: task.CreatedBy, CreatedByName: task.Author.Name, CreatedAt: task.CreatedAt, diff --git a/package/service/task_service_test.go b/package/service/task_service_test.go index 0e6dd626..1f9cfd86 100644 --- a/package/service/task_service_test.go +++ b/package/service/task_service_test.go @@ -296,7 +296,7 @@ func TestGetTask(t *testing.T) { io := mock_repository.NewMockTestCaseRepository(ctrl) fr := mock_repository.NewMockFile(ctrl) config := testutils.NewTestConfig() - fs, err := filestorage.NewFileStorageService(config.FileStorageURL) + fs, err := filestorage.NewFileStorageService(config.FileStorageURL, "test-secret", 300) require.NoError(t, err) ts := service.NewTaskService(fs, fr, tr, io, ur, gr, nil, nil, nil) diff --git a/package/utils/signed_url.go b/package/utils/signed_url.go new file mode 100644 index 00000000..f042d08e --- /dev/null +++ b/package/utils/signed_url.go @@ -0,0 +1,127 @@ +package utils + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net/url" + "strconv" + "time" +) + +var ( + // ErrSignedURLExpired is returned when the signed URL has expired + ErrSignedURLExpired = errors.New("signed URL has expired") + // ErrSignedURLInvalidSignature is returned when the signature validation fails + ErrSignedURLInvalidSignature = errors.New("signed URL has invalid signature") + // ErrSignedURLMissingParams is returned when required parameters are missing + ErrSignedURLMissingParams = errors.New("signed URL is missing required parameters") +) + +// SignedURLGenerator generates and validates signed URLs +type SignedURLGenerator struct { + secretKey []byte + ttl time.Duration +} + +// NewSignedURLGenerator creates a new SignedURLGenerator +func NewSignedURLGenerator(secretKey string, ttlSeconds uint16) *SignedURLGenerator { + return &SignedURLGenerator{ + secretKey: []byte(secretKey), + ttl: time.Duration(ttlSeconds) * time.Second, + } +} + +// GenerateSignedURL generates a signed URL with expiration +// The signature and expiration are added as query parameters +// If ttlSeconds is 0, uses the default TTL from the generator +func (g *SignedURLGenerator) GenerateSignedURL(baseURL string) (string, error) { + return g.GenerateSignedURLWithTTL(baseURL, 0) +} + +// GenerateSignedURLWithTTL generates a signed URL with custom TTL +// If ttlSeconds is 0, uses the default TTL from the generator +func (g *SignedURLGenerator) GenerateSignedURLWithTTL(baseURL string, ttlSeconds uint16) (string, error) { + parsedURL, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("failed to parse base URL: %w", err) + } + + // Use custom TTL if provided, otherwise use default + ttl := g.ttl + if ttlSeconds > 0 { + ttl = time.Duration(ttlSeconds) * time.Second + } + + // Calculate expiration time + expiresAt := time.Now().Add(ttl).Unix() + + // Get existing query parameters + queryParams := parsedURL.Query() + queryParams.Set("expires", strconv.FormatInt(expiresAt, 10)) + + // Create the string to sign (without signature) + parsedURL.RawQuery = queryParams.Encode() + stringToSign := parsedURL.String() + + // Generate signature + signature := g.generateSignature(stringToSign) + + // Add signature to query parameters + queryParams.Set("signature", signature) + parsedURL.RawQuery = queryParams.Encode() + + return parsedURL.String(), nil +} + +// VerifySignedURL verifies a signed URL's signature and expiration +func (g *SignedURLGenerator) VerifySignedURL(signedURL string) error { + parsedURL, err := url.Parse(signedURL) + if err != nil { + return fmt.Errorf("failed to parse signed URL: %w", err) + } + + queryParams := parsedURL.Query() + + // Check for required parameters + expiresStr := queryParams.Get("expires") + providedSignature := queryParams.Get("signature") + + if expiresStr == "" || providedSignature == "" { + return ErrSignedURLMissingParams + } + + // Check expiration + expiresAt, err := strconv.ParseInt(expiresStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid expires parameter: %w", err) + } + + if time.Now().Unix() > expiresAt { + return ErrSignedURLExpired + } + + // Verify signature + // Remove signature from query params to get the original string to sign + queryParams.Del("signature") + parsedURL.RawQuery = queryParams.Encode() + stringToSign := parsedURL.String() + + expectedSignature := g.generateSignature(stringToSign) + + if !hmac.Equal([]byte(providedSignature), []byte(expectedSignature)) { + return ErrSignedURLInvalidSignature + } + + return nil +} + +// generateSignature creates an HMAC-SHA256 signature for the given data +func (g *SignedURLGenerator) generateSignature(data string) string { + h := hmac.New(sha256.New, g.secretKey) + h.Write([]byte(data)) + signature := base64.URLEncoding.EncodeToString(h.Sum(nil)) + return signature +} diff --git a/package/utils/signed_url_test.go b/package/utils/signed_url_test.go new file mode 100644 index 00000000..18a119a4 --- /dev/null +++ b/package/utils/signed_url_test.go @@ -0,0 +1,163 @@ +package utils + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "net/url" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testBaseURL = "http://example.com/file.pdf" + +func TestSignedURLGenerator_GenerateSignedURL(t *testing.T) { + generator := NewSignedURLGenerator("test-secret-key", 300) + + signedURL, err := generator.GenerateSignedURL(testBaseURL) + require.NoError(t, err) + assert.NotEmpty(t, signedURL) + + // Parse the signed URL and check for required parameters + parsedURL, err := url.Parse(signedURL) + require.NoError(t, err) + + queryParams := parsedURL.Query() + assert.NotEmpty(t, queryParams.Get("expires"), "expires parameter should be present") + assert.NotEmpty(t, queryParams.Get("signature"), "signature parameter should be present") + + // Verify the expiration is in the future + expiresStr := queryParams.Get("expires") + expiresAt, err := strconv.ParseInt(expiresStr, 10, 64) + require.NoError(t, err) + assert.Greater(t, expiresAt, time.Now().Unix(), "expiration should be in the future") +} + +func TestSignedURLGenerator_GenerateSignedURL_WithExistingQueryParams(t *testing.T) { + generator := NewSignedURLGenerator("test-secret-key", 300) + baseURL := "http://example.com/file.pdf?existing=param&another=value" + + signedURL, err := generator.GenerateSignedURL(baseURL) + require.NoError(t, err) + + parsedURL, err := url.Parse(signedURL) + require.NoError(t, err) + + queryParams := parsedURL.Query() + assert.Equal(t, "param", queryParams.Get("existing"), "existing params should be preserved") + assert.Equal(t, "value", queryParams.Get("another"), "existing params should be preserved") + assert.NotEmpty(t, queryParams.Get("expires")) + assert.NotEmpty(t, queryParams.Get("signature")) +} + +func TestSignedURLGenerator_VerifySignedURL_Valid(t *testing.T) { + generator := NewSignedURLGenerator("test-secret-key", 300) + + signedURL, err := generator.GenerateSignedURL(testBaseURL) + require.NoError(t, err) + + // Verify the signed URL + err = generator.VerifySignedURL(signedURL) + assert.NoError(t, err, "valid signed URL should verify successfully") +} + +func TestSignedURLGenerator_VerifySignedURL_Expired(t *testing.T) { + generator := NewSignedURLGenerator("test-secret-key", 300) + + // Manually create an expired signed URL + parsedURL, err := url.Parse(testBaseURL) + require.NoError(t, err) + + // Set expiration to 1 second in the past + expiresAt := time.Now().Add(-1 * time.Second).Unix() + queryParams := parsedURL.Query() + queryParams.Set("expires", strconv.FormatInt(expiresAt, 10)) + parsedURL.RawQuery = queryParams.Encode() + + // Generate signature for the expired URL + stringToSign := parsedURL.String() + h := hmac.New(sha256.New, []byte("test-secret-key")) + h.Write([]byte(stringToSign)) + signature := base64.URLEncoding.EncodeToString(h.Sum(nil)) + + queryParams.Set("signature", signature) + parsedURL.RawQuery = queryParams.Encode() + expiredURL := parsedURL.String() + + // Verify should fail due to expiration + err = generator.VerifySignedURL(expiredURL) + assert.ErrorIs(t, err, ErrSignedURLExpired, "expired URL should return ErrSignedURLExpired") +} + +func TestSignedURLGenerator_VerifySignedURL_InvalidSignature(t *testing.T) { + generator := NewSignedURLGenerator("test-secret-key", 300) + + signedURL, err := generator.GenerateSignedURL(testBaseURL) + require.NoError(t, err) + + // Tamper with the URL by changing a query parameter + parsedURL, err := url.Parse(signedURL) + require.NoError(t, err) + + queryParams := parsedURL.Query() + queryParams.Set("tampered", "true") + parsedURL.RawQuery = queryParams.Encode() + tamperedURL := parsedURL.String() + + // Verify should fail due to invalid signature + err = generator.VerifySignedURL(tamperedURL) + assert.ErrorIs(t, err, ErrSignedURLInvalidSignature, "tampered URL should return ErrSignedURLInvalidSignature") +} + +func TestSignedURLGenerator_VerifySignedURL_WrongSecretKey(t *testing.T) { + generator1 := NewSignedURLGenerator("secret-key-1", 300) + generator2 := NewSignedURLGenerator("secret-key-2", 300) + + // Generate URL with generator1 + signedURL, err := generator1.GenerateSignedURL(testBaseURL) + require.NoError(t, err) + + // Try to verify with generator2 (different secret) + err = generator2.VerifySignedURL(signedURL) + assert.ErrorIs(t, err, ErrSignedURLInvalidSignature, "URL signed with different key should fail verification") +} + +func TestSignedURLGenerator_VerifySignedURL_MissingExpires(t *testing.T) { + generator := NewSignedURLGenerator("test-secret-key", 300) + invalidURL := "http://example.com/file.pdf?signature=abc123" + + err := generator.VerifySignedURL(invalidURL) + assert.ErrorIs(t, err, ErrSignedURLMissingParams, "URL without expires should return ErrSignedURLMissingParams") +} + +func TestSignedURLGenerator_VerifySignedURL_MissingSignature(t *testing.T) { + generator := NewSignedURLGenerator("test-secret-key", 300) + invalidURL := "http://example.com/file.pdf?expires=123456789" + + err := generator.VerifySignedURL(invalidURL) + assert.ErrorIs(t, err, ErrSignedURLMissingParams, "URL without signature should return ErrSignedURLMissingParams") +} + +func TestSignedURLGenerator_TTLDuration(t *testing.T) { + ttlSeconds := uint16(120) + generator := NewSignedURLGenerator("test-secret-key", ttlSeconds) + + signedURL, err := generator.GenerateSignedURL(testBaseURL) + require.NoError(t, err) + + parsedURL, err := url.Parse(signedURL) + require.NoError(t, err) + + queryParams := parsedURL.Query() + expiresStr := queryParams.Get("expires") + expiresAt, err := strconv.ParseInt(expiresStr, 10, 64) + require.NoError(t, err) + + expectedExpiration := time.Now().Add(time.Duration(ttlSeconds) * time.Second).Unix() + // Allow 2 second tolerance for test execution time + assert.InDelta(t, expectedExpiration, expiresAt, 2, "expiration should match TTL") +}