Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ module github.com/ubcesports/echo-base

go 1.24

require github.com/lib/pq v1.10.9
require (
github.com/lib/pq v1.10.9
github.com/stretchr/testify v1.8.0
)

require (
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.2.0 // indirect
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
Expand All @@ -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
Expand All @@ -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 (
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
15 changes: 3 additions & 12 deletions internal/interfaces/auth/types.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
136 changes: 128 additions & 8 deletions internal/services/authservice.go
Original file line number Diff line number Diff line change
@@ -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
}
77 changes: 77 additions & 0 deletions internal/services/authservice_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package services

import (
"context"
"fmt"
"strings"
"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)

// 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) {
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)
}