From 629bb15099a57009700e5bf7dd6f2fbfd5d654dd Mon Sep 17 00:00:00 2001 From: Jaden Hums Date: Sat, 18 Oct 2025 14:48:59 -0700 Subject: [PATCH 1/4] auth refactor --- go.mod | 10 +- go.sum | 4 +- internal/interfaces/auth/types.go | 15 +-- internal/services/auth_service_test.go | 66 ++++++++++++ internal/services/authservice.go | 136 +++++++++++++++++++++++-- 5 files changed, 207 insertions(+), 24 deletions(-) create mode 100644 internal/services/auth_service_test.go diff --git a/go.mod b/go.mod index eff4f30..fce4783 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,11 @@ module github.com/ubcesports/echo-base go 1.24 -require github.com/lib/pq v1.10.9 +require ( + github.com/google/uuid v1.3.0 + github.com/lib/pq v1.10.9 + github.com/stretchr/testify v1.8.0 +) require ( github.com/Masterminds/goutils v1.1.1 // indirect @@ -10,6 +14,7 @@ require ( github.com/Masterminds/sprig/v3 v3.2.3 // indirect github.com/armon/go-radix v1.0.0 // indirect github.com/bgentry/speakeasy v0.1.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/denisenkom/go-mssqldb v0.9.0 // indirect github.com/fatih/color v1.13.0 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect @@ -18,7 +23,6 @@ require ( github.com/godror/godror v0.40.4 // indirect github.com/godror/knownpb v0.1.1 // indirect github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect - github.com/google/uuid v1.3.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/huandu/xstrings v1.4.0 // indirect @@ -32,6 +36,7 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/posener/complete v1.2.3 // indirect github.com/rubenv/sql-migrate v1.8.0 // indirect github.com/shopspring/decimal v1.3.1 // indirect @@ -41,6 +46,7 @@ require ( golang.org/x/sys v0.32.0 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) tool ( diff --git a/go.sum b/go.sum index 6c5a37c..c686614 100644 --- a/go.sum +++ b/go.sum @@ -35,8 +35,6 @@ github.com/godror/godror v0.40.4 h1:X1e7hUd02GDaLWKZj40Z7L0CP0W9TrGgmPQZw6+anBg= github.com/godror/godror v0.40.4/go.mod h1:i8YtVTHUJKfFT3wTat4A9UoqScUtZXiYB9Rf3SVARgc= github.com/godror/knownpb v0.1.1 h1:A4J7jdx7jWBhJm18NntafzSC//iZDHkDi1+juwQ5pTI= github.com/godror/knownpb v0.1.1/go.mod h1:4nRFbQo1dDuwKnblRXDxrfCFYeT4hjg3GjMqef58eRE= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= @@ -111,10 +109,12 @@ github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/internal/interfaces/auth/types.go b/internal/interfaces/auth/types.go index 565eee0..6fd49f6 100644 --- a/internal/interfaces/auth/types.go +++ b/internal/interfaces/auth/types.go @@ -1,18 +1,9 @@ package auth -import ( - "time" - - "github.com/google/uuid" -) - type Application struct { - Id uuid.UUID - AppName string - KeyId string - HashedKey []byte - CreatedAt time.Time - LastUsedAt time.Time + AppName string + KeyId string + HashedKey []byte } type APIKey struct { diff --git a/internal/services/auth_service_test.go b/internal/services/auth_service_test.go new file mode 100644 index 0000000..8e643dc --- /dev/null +++ b/internal/services/auth_service_test.go @@ -0,0 +1,66 @@ +package services + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/ubcesports/echo-base/internal/interfaces/auth" +) + +type mockAuthRepository struct { + applications map[string]*auth.Application +} + +func (m *mockAuthRepository) Store(ctx context.Context, app *auth.Application) error { + m.applications[app.KeyId] = app + return nil +} +func (m *mockAuthRepository) FindKeyById(ctx context.Context, keyId string) (*auth.Application, error) { + app, exists := m.applications[keyId] + if !exists { + return nil, fmt.Errorf("error") + } + return app, nil +} +func (m *mockAuthRepository) UpdateLastUsed(ctx context.Context, keyId string) error { + return nil +} + +func TestGenerateAPIKey(t *testing.T) { + mockRepo := &mockAuthRepository{ + applications: make(map[string]*auth.Application), + } + authService := NewAuthService(mockRepo) + apiKey, err := authService.GenerateAPIKey(context.Background(), "test-app") + assert.NoError(t, err) + assert.Equal(t, "test-app", apiKey.AppName) +} + +func TestValidateAPIKey(t *testing.T) { + mockRepo := &mockAuthRepository{ + applications: make(map[string]*auth.Application), + } + + authService := NewAuthService(mockRepo) + rawSecret := "supersecret" + hashedSecret := authService.hashSecret(rawSecret) + mockApp := auth.Application{ + KeyId: "testid", + HashedKey: hashedSecret, + AppName: "test-app", + } + mockRepo.Store(context.Background(), &mockApp) + + // Valid API key + apiKey := fmt.Sprintf("api_%s.%s", mockApp.KeyId, rawSecret) + gotAppName, err := authService.ValidateAPIKey(context.Background(), apiKey) + assert.NoError(t, err) + assert.Equal(t, mockApp.AppName, gotAppName) + + // Invalid secret + badApiKey := fmt.Sprintf("api_%s.%s", mockApp.KeyId, "wrongsecret") + _, err = authService.ValidateAPIKey(context.Background(), badApiKey) + assert.Error(t, err) +} diff --git a/internal/services/authservice.go b/internal/services/authservice.go index ecb84b7..57a7133 100644 --- a/internal/services/authservice.go +++ b/internal/services/authservice.go @@ -1,23 +1,143 @@ -package auth +package services import ( "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base32" + "encoding/base64" + "fmt" + "regexp" + "strings" "github.com/ubcesports/echo-base/internal/interfaces/auth" ) -type Service struct { +const ( + KeyIDLength = 6 + SecretLength = 32 + APIKeyPrefix = "api_" + MaxAppNameLength = 100 +) + +var ( + validAppNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) +) + +type AuthService struct { repo auth.AuthRepository } -func NewService(repo auth.AuthRepository) *Service { - return &Service{repo: repo} +func NewAuthService(repo auth.AuthRepository) *AuthService { + return &AuthService{repo: repo} +} + +func (s *AuthService) GenerateAPIKey(ctx context.Context, appName string) (*auth.APIKey, error) { + if err := s.validateAppName(appName); err != nil { + return nil, err + } + + keyId, secret, err := s.genereateCredentials() + if err != nil { + return nil, err + } + + hashedSecret := s.hashSecret(secret) + app := &auth.Application{ + AppName: appName, + KeyId: keyId, + HashedKey: hashedSecret, + } + + if err := s.repo.Store(ctx, app); err != nil { + return nil, fmt.Errorf("failed to store API key: %w", err) + } + return &auth.APIKey{ + KeyId: keyId, + APIKey: fmt.Sprintf("api_%s.%s", keyId, secret), + AppName: appName, + }, nil +} + +func (s *AuthService) ValidateAPIKey(ctx context.Context, apiKey string) (string, error) { + + keyId, secret, err := s.parseAPIKey(apiKey) + + app, err := s.repo.FindKeyById(ctx, keyId) + if err != nil { + return "", err + } + if !s.verifySecret(secret, app.HashedKey) { + return "", fmt.Errorf("invalid api key") + } + + go func() { + s.repo.UpdateLastUsed(context.Background(), keyId) + }() + + return app.AppName, nil +} + +func (s *AuthService) genereateCredentials() (string, string, error) { + keyIDBytes := make([]byte, KeyIDLength) + _, err := rand.Read(keyIDBytes) + if err != nil { + return "", "", err + } + keyId := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(keyIDBytes)) + + secretBytes := make([]byte, SecretLength) + _, err = rand.Read(secretBytes) + if err != nil { + return "", "", err + } + secret := base64.RawURLEncoding.EncodeToString(secretBytes) + return keyId, secret, nil +} + +func (s *AuthService) hashSecret(secret string) []byte { + hasher := sha256.New() + hasher.Write([]byte(secret)) + hashedSecret := hasher.Sum(nil) + return hashedSecret +} + +func (s *AuthService) validateAppName(appName string) error { + if appName == "" { + return fmt.Errorf("app_name is required") + } + + if len(appName) > MaxAppNameLength { + return fmt.Errorf("app_name must be %d characters or less", MaxAppNameLength) + } + + if !validAppNameRegex.MatchString(appName) { + return fmt.Errorf("app_name can only contain letters, numbers, hyphens, and underscores") + } + + return nil } -func (s *Service) GenerateAPIKey(ctx context.Context, appName string) (*auth.APIKey, error) { - return nil, nil +func (s *AuthService) parseAPIKey(apiKey string) (string, string, error) { + if !strings.HasPrefix(apiKey, "api_") { + return "", "", fmt.Errorf("invalid api_key format, missing \"api_\" prefix") + } + + keyParts := strings.Split(strings.TrimPrefix(apiKey, "api_"), ".") + if len(keyParts) != 2 { + return "", "", fmt.Errorf("invalid api_key format, missing parts") + } + + keyId := keyParts[0] + secret := keyParts[1] + return keyId, secret, nil } -func (s *Service) ValidateAPIKey(ctx context.Context, apiKey string) (string, error) { - return "", nil +func (s *AuthService) verifySecret(secret string, hashedSecret []byte) bool { + hasher := sha256.New() + hasher.Write([]byte(secret)) + actualHash := hasher.Sum(nil) + + return subtle.ConstantTimeCompare(hashedSecret, actualHash) == 1 } From 310b4fc7472aad5e3d687a6212a4493a5d01944b Mon Sep 17 00:00:00 2001 From: Jaden Hums Date: Sat, 18 Oct 2025 14:51:01 -0700 Subject: [PATCH 2/4] tidy --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index fce4783..646e379 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/ubcesports/echo-base go 1.24 require ( - github.com/google/uuid v1.3.0 github.com/lib/pq v1.10.9 github.com/stretchr/testify v1.8.0 ) @@ -23,6 +22,7 @@ require ( github.com/godror/godror v0.40.4 // indirect github.com/godror/knownpb v0.1.1 // indirect github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect + github.com/google/uuid v1.3.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/huandu/xstrings v1.4.0 // indirect From 6d46131b11c756475d4f70f389ae9ddbb34db036 Mon Sep 17 00:00:00 2001 From: Jaden Hums Date: Sat, 18 Oct 2025 14:56:09 -0700 Subject: [PATCH 3/4] add more coverage --- internal/services/auth_service_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/services/auth_service_test.go b/internal/services/auth_service_test.go index 8e643dc..8b03f37 100644 --- a/internal/services/auth_service_test.go +++ b/internal/services/auth_service_test.go @@ -3,6 +3,7 @@ package services import ( "context" "fmt" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -33,9 +34,19 @@ func TestGenerateAPIKey(t *testing.T) { applications: make(map[string]*auth.Application), } authService := NewAuthService(mockRepo) + + // Valid Key apiKey, err := authService.GenerateAPIKey(context.Background(), "test-app") assert.NoError(t, err) assert.Equal(t, "test-app", apiKey.AppName) + + // Invalid key, no app name + apiKey, err = authService.GenerateAPIKey(context.Background(), "") + assert.Error(t, err) + + // Invalid key, too long + apiKey, err = authService.GenerateAPIKey(context.Background(), strings.Repeat("a", 101)) + assert.Error(t, err) } func TestValidateAPIKey(t *testing.T) { From fb0c08466f79e967d5747acdfa84aa95b5993ef0 Mon Sep 17 00:00:00 2001 From: Jaden Hums Date: Sat, 18 Oct 2025 14:58:37 -0700 Subject: [PATCH 4/4] file rename --- internal/services/{auth_service_test.go => authservice_test.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename internal/services/{auth_service_test.go => authservice_test.go} (100%) diff --git a/internal/services/auth_service_test.go b/internal/services/authservice_test.go similarity index 100% rename from internal/services/auth_service_test.go rename to internal/services/authservice_test.go