feat(cache): implement hashed API key storage and retrieval
Adds a new hashed key storage mechanism for API keys in the cache. Replaces direct mapping to API keys with composite keys based on organizationId and name. Implements searching of API keys using hash comparisons for improved security. Updates related tests to ensure correct functionality and validate the hashing. Also, adds support for a new dependency `golang.org/x/crypto`.
This commit is contained in:
Vendored
+22
-12
@@ -14,7 +14,7 @@ import (
|
|||||||
type Cache struct {
|
type Cache struct {
|
||||||
organizations map[string]domain.Organization
|
organizations map[string]domain.Organization
|
||||||
users map[string][]string
|
users map[string][]string
|
||||||
apiKeys map[string]domain.APIKey
|
apiKeys map[string]domain.APIKey // keyed by organizationId-name
|
||||||
services map[string]map[string]map[string]struct{}
|
services map[string]map[string]map[string]struct{}
|
||||||
subGraphs map[string]string
|
subGraphs map[string]string
|
||||||
lastUpdate map[string]string
|
lastUpdate map[string]string
|
||||||
@@ -22,15 +22,17 @@ type Cache struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) OrganizationByAPIKey(apiKey string) *domain.Organization {
|
func (c *Cache) OrganizationByAPIKey(apiKey string) *domain.Organization {
|
||||||
key, exists := c.apiKeys[apiKey]
|
// Find the API key by comparing hashes
|
||||||
if !exists {
|
for _, key := range c.apiKeys {
|
||||||
return nil
|
if hash.CompareAPIKey(key.Key, apiKey) {
|
||||||
}
|
|
||||||
org, exists := c.organizations[key.OrganizationId]
|
org, exists := c.organizations[key.OrganizationId]
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &org
|
return &org
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) OrganizationsByUser(sub string) []domain.Organization {
|
func (c *Cache) OrganizationsByUser(sub string) []domain.Organization {
|
||||||
@@ -43,11 +45,13 @@ func (c *Cache) OrganizationsByUser(sub string) []domain.Organization {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) ApiKeyByKey(key string) *domain.APIKey {
|
func (c *Cache) ApiKeyByKey(key string) *domain.APIKey {
|
||||||
k, exists := c.apiKeys[hash.String(key)]
|
// Find the API key by comparing hashes
|
||||||
if !exists {
|
for _, apiKey := range c.apiKeys {
|
||||||
return nil
|
if hash.CompareAPIKey(apiKey.Key, key) {
|
||||||
|
return &apiKey
|
||||||
}
|
}
|
||||||
return &k
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) Services(orgId, ref, lastUpdate string) ([]string, string) {
|
func (c *Cache) Services(orgId, ref, lastUpdate string) ([]string, string) {
|
||||||
@@ -76,14 +80,15 @@ func (c *Cache) Update(msg any, _ goamqp.Headers) (any, error) {
|
|||||||
key := domain.APIKey{
|
key := domain.APIKey{
|
||||||
Name: m.Name,
|
Name: m.Name,
|
||||||
OrganizationId: m.OrganizationId,
|
OrganizationId: m.OrganizationId,
|
||||||
Key: m.Key,
|
Key: m.Key, // This is now the hashed key
|
||||||
Refs: m.Refs,
|
Refs: m.Refs,
|
||||||
Read: m.Read,
|
Read: m.Read,
|
||||||
Publish: m.Publish,
|
Publish: m.Publish,
|
||||||
CreatedBy: m.Initiator,
|
CreatedBy: m.Initiator,
|
||||||
CreatedAt: m.When(),
|
CreatedAt: m.When(),
|
||||||
}
|
}
|
||||||
c.apiKeys[m.Key] = key
|
// Use composite key: organizationId-name
|
||||||
|
c.apiKeys[apiKeyId(m.OrganizationId, m.Name)] = key
|
||||||
org := c.organizations[m.OrganizationId]
|
org := c.organizations[m.OrganizationId]
|
||||||
org.APIKeys = append(org.APIKeys, key)
|
org.APIKeys = append(org.APIKeys, key)
|
||||||
c.organizations[m.OrganizationId] = org
|
c.organizations[m.OrganizationId] = org
|
||||||
@@ -93,7 +98,8 @@ func (c *Cache) Update(msg any, _ goamqp.Headers) (any, error) {
|
|||||||
c.organizations[m.ID.String()] = *m
|
c.organizations[m.ID.String()] = *m
|
||||||
c.addUser(m.CreatedBy, *m)
|
c.addUser(m.CreatedBy, *m)
|
||||||
for _, k := range m.APIKeys {
|
for _, k := range m.APIKeys {
|
||||||
c.apiKeys[k.Key] = k
|
// Use composite key: organizationId-name
|
||||||
|
c.apiKeys[apiKeyId(k.OrganizationId, k.Name)] = k
|
||||||
}
|
}
|
||||||
case *domain.SubGraph:
|
case *domain.SubGraph:
|
||||||
c.updateSubGraph(m.OrganizationId, m.Ref, m.ID.String(), m.Service, m.ChangedAt)
|
c.updateSubGraph(m.OrganizationId, m.Ref, m.ID.String(), m.Service, m.ChangedAt)
|
||||||
@@ -143,3 +149,7 @@ func refKey(orgId string, ref string) string {
|
|||||||
func subGraphKey(orgId string, ref string, service string) string {
|
func subGraphKey(orgId string, ref string, service string) string {
|
||||||
return fmt.Sprintf("%s<->%s<->%s", orgId, ref, service)
|
return fmt.Sprintf("%s<->%s<->%s", orgId, ref, service)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func apiKeyId(orgId string, name string) string {
|
||||||
|
return fmt.Sprintf("%s<->%s", orgId, name)
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import (
|
|||||||
"gitlab.com/unboundsoftware/schemas/domain"
|
"gitlab.com/unboundsoftware/schemas/domain"
|
||||||
"gitlab.com/unboundsoftware/schemas/graph"
|
"gitlab.com/unboundsoftware/schemas/graph"
|
||||||
"gitlab.com/unboundsoftware/schemas/graph/generated"
|
"gitlab.com/unboundsoftware/schemas/graph/generated"
|
||||||
"gitlab.com/unboundsoftware/schemas/hash"
|
|
||||||
"gitlab.com/unboundsoftware/schemas/logging"
|
"gitlab.com/unboundsoftware/schemas/logging"
|
||||||
"gitlab.com/unboundsoftware/schemas/middleware"
|
"gitlab.com/unboundsoftware/schemas/middleware"
|
||||||
"gitlab.com/unboundsoftware/schemas/monitoring"
|
"gitlab.com/unboundsoftware/schemas/monitoring"
|
||||||
@@ -217,8 +216,8 @@ func start(closeEvents chan error, logger *slog.Logger, connectToAmqpFunc func(u
|
|||||||
logger.Info("WebSocket connection with API key", "has_key", true)
|
logger.Info("WebSocket connection with API key", "has_key", true)
|
||||||
ctx = context.WithValue(ctx, middleware.ApiKey, apiKey)
|
ctx = context.WithValue(ctx, middleware.ApiKey, apiKey)
|
||||||
|
|
||||||
// Look up organization by API key (same logic as auth middleware)
|
// Look up organization by API key (cache handles hash comparison)
|
||||||
if organization := serviceCache.OrganizationByAPIKey(hash.String(apiKey)); organization != nil {
|
if organization := serviceCache.OrganizationByAPIKey(apiKey); organization != nil {
|
||||||
logger.Info("WebSocket: Organization found for API key", "org_id", organization.ID.String())
|
logger.Info("WebSocket: Organization found for API key", "org_id", organization.ID.String())
|
||||||
ctx = context.WithValue(ctx, middleware.OrganizationKey, *organization)
|
ctx = context.WithValue(ctx, middleware.OrganizationKey, *organization)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+47
-21
@@ -17,11 +17,18 @@ import (
|
|||||||
|
|
||||||
// MockCache is a mock implementation for testing
|
// MockCache is a mock implementation for testing
|
||||||
type MockCache struct {
|
type MockCache struct {
|
||||||
organizations map[string]*domain.Organization
|
organizations map[string]*domain.Organization // keyed by orgId-name composite
|
||||||
|
apiKeys map[string]string // maps orgId-name to hashed key
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockCache) OrganizationByAPIKey(apiKey string) *domain.Organization {
|
func (m *MockCache) OrganizationByAPIKey(plainKey string) *domain.Organization {
|
||||||
return m.organizations[apiKey]
|
// Find organization by comparing plaintext key with stored hash
|
||||||
|
for compositeKey, hashedKey := range m.apiKeys {
|
||||||
|
if hash.CompareAPIKey(hashedKey, plainKey) {
|
||||||
|
return m.organizations[compositeKey]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebSocketInitFunc_WithValidAPIKey(t *testing.T) {
|
func TestWebSocketInitFunc_WithValidAPIKey(t *testing.T) {
|
||||||
@@ -35,11 +42,17 @@ func TestWebSocketInitFunc_WithValidAPIKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apiKey := "test-api-key-123"
|
apiKey := "test-api-key-123"
|
||||||
hashedKey := hash.String(apiKey)
|
hashedKey, err := hash.APIKey(apiKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
compositeKey := orgID.String() + "-test-key"
|
||||||
|
|
||||||
mockCache := &MockCache{
|
mockCache := &MockCache{
|
||||||
organizations: map[string]*domain.Organization{
|
organizations: map[string]*domain.Organization{
|
||||||
hashedKey: org,
|
compositeKey: org,
|
||||||
|
},
|
||||||
|
apiKeys: map[string]string{
|
||||||
|
compositeKey: hashedKey,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +62,8 @@ func TestWebSocketInitFunc_WithValidAPIKey(t *testing.T) {
|
|||||||
if apiKey, ok := initPayload["X-Api-Key"].(string); ok && apiKey != "" {
|
if apiKey, ok := initPayload["X-Api-Key"].(string); ok && apiKey != "" {
|
||||||
ctx = context.WithValue(ctx, middleware.ApiKey, apiKey)
|
ctx = context.WithValue(ctx, middleware.ApiKey, apiKey)
|
||||||
|
|
||||||
// Look up organization by API key
|
// Look up organization by API key (cache handles hash comparison)
|
||||||
if organization := mockCache.OrganizationByAPIKey(hash.String(apiKey)); organization != nil {
|
if organization := mockCache.OrganizationByAPIKey(apiKey); organization != nil {
|
||||||
ctx = context.WithValue(ctx, middleware.OrganizationKey, *organization)
|
ctx = context.WithValue(ctx, middleware.OrganizationKey, *organization)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,6 +104,7 @@ func TestWebSocketInitFunc_WithInvalidAPIKey(t *testing.T) {
|
|||||||
// Setup
|
// Setup
|
||||||
mockCache := &MockCache{
|
mockCache := &MockCache{
|
||||||
organizations: map[string]*domain.Organization{},
|
organizations: map[string]*domain.Organization{},
|
||||||
|
apiKeys: map[string]string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
apiKey := "invalid-api-key"
|
apiKey := "invalid-api-key"
|
||||||
@@ -101,8 +115,8 @@ func TestWebSocketInitFunc_WithInvalidAPIKey(t *testing.T) {
|
|||||||
if apiKey, ok := initPayload["X-Api-Key"].(string); ok && apiKey != "" {
|
if apiKey, ok := initPayload["X-Api-Key"].(string); ok && apiKey != "" {
|
||||||
ctx = context.WithValue(ctx, middleware.ApiKey, apiKey)
|
ctx = context.WithValue(ctx, middleware.ApiKey, apiKey)
|
||||||
|
|
||||||
// Look up organization by API key
|
// Look up organization by API key (cache handles hash comparison)
|
||||||
if organization := mockCache.OrganizationByAPIKey(hash.String(apiKey)); organization != nil {
|
if organization := mockCache.OrganizationByAPIKey(apiKey); organization != nil {
|
||||||
ctx = context.WithValue(ctx, middleware.OrganizationKey, *organization)
|
ctx = context.WithValue(ctx, middleware.OrganizationKey, *organization)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +151,7 @@ func TestWebSocketInitFunc_WithoutAPIKey(t *testing.T) {
|
|||||||
// Setup
|
// Setup
|
||||||
mockCache := &MockCache{
|
mockCache := &MockCache{
|
||||||
organizations: map[string]*domain.Organization{},
|
organizations: map[string]*domain.Organization{},
|
||||||
|
apiKeys: map[string]string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create InitFunc
|
// Create InitFunc
|
||||||
@@ -145,8 +160,8 @@ func TestWebSocketInitFunc_WithoutAPIKey(t *testing.T) {
|
|||||||
if apiKey, ok := initPayload["X-Api-Key"].(string); ok && apiKey != "" {
|
if apiKey, ok := initPayload["X-Api-Key"].(string); ok && apiKey != "" {
|
||||||
ctx = context.WithValue(ctx, middleware.ApiKey, apiKey)
|
ctx = context.WithValue(ctx, middleware.ApiKey, apiKey)
|
||||||
|
|
||||||
// Look up organization by API key
|
// Look up organization by API key (cache handles hash comparison)
|
||||||
if organization := mockCache.OrganizationByAPIKey(hash.String(apiKey)); organization != nil {
|
if organization := mockCache.OrganizationByAPIKey(apiKey); organization != nil {
|
||||||
ctx = context.WithValue(ctx, middleware.OrganizationKey, *organization)
|
ctx = context.WithValue(ctx, middleware.OrganizationKey, *organization)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,6 +191,7 @@ func TestWebSocketInitFunc_WithEmptyAPIKey(t *testing.T) {
|
|||||||
// Setup
|
// Setup
|
||||||
mockCache := &MockCache{
|
mockCache := &MockCache{
|
||||||
organizations: map[string]*domain.Organization{},
|
organizations: map[string]*domain.Organization{},
|
||||||
|
apiKeys: map[string]string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create InitFunc
|
// Create InitFunc
|
||||||
@@ -184,8 +200,8 @@ func TestWebSocketInitFunc_WithEmptyAPIKey(t *testing.T) {
|
|||||||
if apiKey, ok := initPayload["X-Api-Key"].(string); ok && apiKey != "" {
|
if apiKey, ok := initPayload["X-Api-Key"].(string); ok && apiKey != "" {
|
||||||
ctx = context.WithValue(ctx, middleware.ApiKey, apiKey)
|
ctx = context.WithValue(ctx, middleware.ApiKey, apiKey)
|
||||||
|
|
||||||
// Look up organization by API key
|
// Look up organization by API key (cache handles hash comparison)
|
||||||
if organization := mockCache.OrganizationByAPIKey(hash.String(apiKey)); organization != nil {
|
if organization := mockCache.OrganizationByAPIKey(apiKey); organization != nil {
|
||||||
ctx = context.WithValue(ctx, middleware.OrganizationKey, *organization)
|
ctx = context.WithValue(ctx, middleware.OrganizationKey, *organization)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,6 +233,7 @@ func TestWebSocketInitFunc_WithWrongTypeAPIKey(t *testing.T) {
|
|||||||
// Setup
|
// Setup
|
||||||
mockCache := &MockCache{
|
mockCache := &MockCache{
|
||||||
organizations: map[string]*domain.Organization{},
|
organizations: map[string]*domain.Organization{},
|
||||||
|
apiKeys: map[string]string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create InitFunc
|
// Create InitFunc
|
||||||
@@ -225,8 +242,8 @@ func TestWebSocketInitFunc_WithWrongTypeAPIKey(t *testing.T) {
|
|||||||
if apiKey, ok := initPayload["X-Api-Key"].(string); ok && apiKey != "" {
|
if apiKey, ok := initPayload["X-Api-Key"].(string); ok && apiKey != "" {
|
||||||
ctx = context.WithValue(ctx, middleware.ApiKey, apiKey)
|
ctx = context.WithValue(ctx, middleware.ApiKey, apiKey)
|
||||||
|
|
||||||
// Look up organization by API key
|
// Look up organization by API key (cache handles hash comparison)
|
||||||
if organization := mockCache.OrganizationByAPIKey(hash.String(apiKey)); organization != nil {
|
if organization := mockCache.OrganizationByAPIKey(apiKey); organization != nil {
|
||||||
ctx = context.WithValue(ctx, middleware.OrganizationKey, *organization)
|
ctx = context.WithValue(ctx, middleware.OrganizationKey, *organization)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,13 +291,22 @@ func TestWebSocketInitFunc_WithMultipleOrganizations(t *testing.T) {
|
|||||||
|
|
||||||
apiKey1 := "api-key-org-1"
|
apiKey1 := "api-key-org-1"
|
||||||
apiKey2 := "api-key-org-2"
|
apiKey2 := "api-key-org-2"
|
||||||
hashedKey1 := hash.String(apiKey1)
|
hashedKey1, err := hash.APIKey(apiKey1)
|
||||||
hashedKey2 := hash.String(apiKey2)
|
require.NoError(t, err)
|
||||||
|
hashedKey2, err := hash.APIKey(apiKey2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
compositeKey1 := org1ID.String() + "-key1"
|
||||||
|
compositeKey2 := org2ID.String() + "-key2"
|
||||||
|
|
||||||
mockCache := &MockCache{
|
mockCache := &MockCache{
|
||||||
organizations: map[string]*domain.Organization{
|
organizations: map[string]*domain.Organization{
|
||||||
hashedKey1: org1,
|
compositeKey1: org1,
|
||||||
hashedKey2: org2,
|
compositeKey2: org2,
|
||||||
|
},
|
||||||
|
apiKeys: map[string]string{
|
||||||
|
compositeKey1: hashedKey1,
|
||||||
|
compositeKey2: hashedKey2,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,8 +316,8 @@ func TestWebSocketInitFunc_WithMultipleOrganizations(t *testing.T) {
|
|||||||
if apiKey, ok := initPayload["X-Api-Key"].(string); ok && apiKey != "" {
|
if apiKey, ok := initPayload["X-Api-Key"].(string); ok && apiKey != "" {
|
||||||
ctx = context.WithValue(ctx, middleware.ApiKey, apiKey)
|
ctx = context.WithValue(ctx, middleware.ApiKey, apiKey)
|
||||||
|
|
||||||
// Look up organization by API key
|
// Look up organization by API key (cache handles hash comparison)
|
||||||
if organization := mockCache.OrganizationByAPIKey(hash.String(apiKey)); organization != nil {
|
if organization := mockCache.OrganizationByAPIKey(apiKey); organization != nil {
|
||||||
ctx = context.WithValue(ctx, middleware.OrganizationKey, *organization)
|
ctx = context.WithValue(ctx, middleware.OrganizationKey, *organization)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -56,9 +56,20 @@ func (a AddAPIKey) Validate(_ context.Context, aggregate eventsourced.Aggregate)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a AddAPIKey) Event(context.Context) eventsourced.Event {
|
func (a AddAPIKey) Event(context.Context) eventsourced.Event {
|
||||||
|
// Hash the API key using bcrypt for secure storage
|
||||||
|
// Note: We can't return an error here, but bcrypt errors are extremely rare
|
||||||
|
// (only if system runs out of memory or bcrypt cost is invalid)
|
||||||
|
// We use a fixed cost of 12 which is always valid
|
||||||
|
hashedKey, err := hash.APIKey(a.Key)
|
||||||
|
if err != nil {
|
||||||
|
// This should never happen with bcrypt cost 12, but if it does,
|
||||||
|
// we'll store an empty hash which will fail validation later
|
||||||
|
hashedKey = ""
|
||||||
|
}
|
||||||
|
|
||||||
return &APIKeyAdded{
|
return &APIKeyAdded{
|
||||||
Name: a.Name,
|
Name: a.Name,
|
||||||
Key: hash.String(a.Key),
|
Key: hashedKey,
|
||||||
Refs: a.Refs,
|
Refs: a.Refs,
|
||||||
Read: a.Read,
|
Read: a.Read,
|
||||||
Publish: a.Publish,
|
Publish: a.Publish,
|
||||||
|
|||||||
+24
-11
@@ -2,10 +2,13 @@ package domain
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"gitlab.com/unboundsoftware/eventsourced/eventsourced"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"gitlab.com/unboundsoftware/schemas/hash"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAddAPIKey_Event(t *testing.T) {
|
func TestAddAPIKey_Event(t *testing.T) {
|
||||||
@@ -24,7 +27,6 @@ func TestAddAPIKey_Event(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
fields fields
|
fields fields
|
||||||
args args
|
args args
|
||||||
want eventsourced.Event
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "event",
|
name: "event",
|
||||||
@@ -37,14 +39,6 @@ func TestAddAPIKey_Event(t *testing.T) {
|
|||||||
Initiator: "jim@example.org",
|
Initiator: "jim@example.org",
|
||||||
},
|
},
|
||||||
args: args{},
|
args: args{},
|
||||||
want: &APIKeyAdded{
|
|
||||||
Name: "test",
|
|
||||||
Key: "dXNfYWtfMTIzNDU2Nzg5MDEyMzQ1NuOwxEKY/BwUmvv0yJlvuSQnrkHkZJuTTKSVmRt4UrhV",
|
|
||||||
Refs: []string{"Example@dev"},
|
|
||||||
Read: true,
|
|
||||||
Publish: true,
|
|
||||||
Initiator: "jim@example.org",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -57,7 +51,26 @@ func TestAddAPIKey_Event(t *testing.T) {
|
|||||||
Publish: tt.fields.Publish,
|
Publish: tt.fields.Publish,
|
||||||
Initiator: tt.fields.Initiator,
|
Initiator: tt.fields.Initiator,
|
||||||
}
|
}
|
||||||
assert.Equalf(t, tt.want, a.Event(tt.args.in0), "Event(%v)", tt.args.in0)
|
event := a.Event(tt.args.in0)
|
||||||
|
require.NotNil(t, event)
|
||||||
|
|
||||||
|
// Cast to APIKeyAdded to verify fields
|
||||||
|
apiKeyEvent, ok := event.(*APIKeyAdded)
|
||||||
|
require.True(t, ok, "Event should be *APIKeyAdded")
|
||||||
|
|
||||||
|
// Verify non-key fields match exactly
|
||||||
|
assert.Equal(t, tt.fields.Name, apiKeyEvent.Name)
|
||||||
|
assert.Equal(t, tt.fields.Refs, apiKeyEvent.Refs)
|
||||||
|
assert.Equal(t, tt.fields.Read, apiKeyEvent.Read)
|
||||||
|
assert.Equal(t, tt.fields.Publish, apiKeyEvent.Publish)
|
||||||
|
assert.Equal(t, tt.fields.Initiator, apiKeyEvent.Initiator)
|
||||||
|
|
||||||
|
// Verify the key is hashed correctly (bcrypt format)
|
||||||
|
assert.True(t, strings.HasPrefix(apiKeyEvent.Key, "$2"), "Key should be bcrypt hashed")
|
||||||
|
assert.NotEqual(t, tt.fields.Key, apiKeyEvent.Key, "Key should be hashed, not plaintext")
|
||||||
|
|
||||||
|
// Verify the hash matches the original key
|
||||||
|
assert.True(t, hash.CompareAPIKey(apiKeyEvent.Key, tt.fields.Key), "Hashed key should match original")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ require (
|
|||||||
go.opentelemetry.io/otel/sdk/log v0.14.0
|
go.opentelemetry.io/otel/sdk/log v0.14.0
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.38.0
|
go.opentelemetry.io/otel/sdk/metric v1.38.0
|
||||||
go.opentelemetry.io/otel/trace v1.38.0
|
go.opentelemetry.io/otel/trace v1.38.0
|
||||||
|
golang.org/x/crypto v0.43.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ func ToGqlAPIKeys(keys []domain.APIKey) []*model.APIKey {
|
|||||||
result[i] = &model.APIKey{
|
result[i] = &model.APIKey{
|
||||||
ID: apiKeyId(k.OrganizationId, k.Name),
|
ID: apiKeyId(k.OrganizationId, k.Name),
|
||||||
Name: k.Name,
|
Name: k.Name,
|
||||||
Key: &k.Key,
|
Key: nil, // Never return the hashed key - only return plaintext on creation
|
||||||
Organization: nil,
|
Organization: nil,
|
||||||
Refs: k.Refs,
|
Refs: k.Refs,
|
||||||
Read: k.Read,
|
Read: k.Read,
|
||||||
|
|||||||
@@ -3,9 +3,72 @@ package hash
|
|||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// String creates a SHA256 hash of a string (legacy, for non-sensitive data)
|
||||||
func String(s string) string {
|
func String(s string) string {
|
||||||
encoded := sha256.New().Sum([]byte(s))
|
encoded := sha256.New().Sum([]byte(s))
|
||||||
return base64.StdEncoding.EncodeToString(encoded)
|
return base64.StdEncoding.EncodeToString(encoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// APIKey hashes an API key using bcrypt for secure storage
|
||||||
|
// Cost of 12 provides a good balance between security and performance
|
||||||
|
func APIKey(key string) (string, error) {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(key), 12)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hash), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareAPIKey compares a plaintext API key with a hash
|
||||||
|
// Supports both bcrypt (new) and SHA256 (legacy) hashes for backwards compatibility
|
||||||
|
// Returns true if they match, false otherwise
|
||||||
|
//
|
||||||
|
// Migration Strategy:
|
||||||
|
// Old API keys stored with SHA256 will continue to work. To upgrade them to bcrypt:
|
||||||
|
// 1. Keys are automatically upgraded when users re-authenticate (if implemented)
|
||||||
|
// 2. Or, run a one-time migration using MigrateAPIKeyHash when convenient
|
||||||
|
func CompareAPIKey(hashedKey, plainKey string) bool {
|
||||||
|
// Bcrypt hashes start with $2a$, $2b$, or $2y$
|
||||||
|
// If the hash starts with $2, it's a bcrypt hash
|
||||||
|
if len(hashedKey) > 2 && hashedKey[0] == '$' && hashedKey[1] == '2' {
|
||||||
|
// New bcrypt hash
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hashedKey), []byte(plainKey))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy SHA256 hash - compare using the old method
|
||||||
|
legacyHash := String(plainKey)
|
||||||
|
return hashedKey == legacyHash
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLegacyHash returns true if the hash is a legacy SHA256 hash (not bcrypt)
|
||||||
|
func IsLegacyHash(hashedKey string) bool {
|
||||||
|
return len(hashedKey) <= 2 || hashedKey[0] != '$' || hashedKey[1] != '2'
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrateAPIKeyHash can be used to upgrade a legacy SHA256 hash to bcrypt
|
||||||
|
// This is useful for one-time migrations of existing keys
|
||||||
|
// Returns the new bcrypt hash if the key is legacy, otherwise returns the original
|
||||||
|
func MigrateAPIKeyHash(currentHash, plainKey string) (string, bool, error) {
|
||||||
|
// If already bcrypt, no migration needed
|
||||||
|
if !IsLegacyHash(currentHash) {
|
||||||
|
return currentHash, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the legacy hash is correct before migrating
|
||||||
|
if !CompareAPIKey(currentHash, plainKey) {
|
||||||
|
return "", false, nil // Invalid key, don't migrate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new bcrypt hash
|
||||||
|
newHash, err := APIKey(plainKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return newHash, true, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package hash
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPIKey(t *testing.T) {
|
||||||
|
key := "test_api_key_12345" // gitleaks:allow
|
||||||
|
|
||||||
|
hash1, err := APIKey(key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, hash1)
|
||||||
|
assert.NotEqual(t, key, hash1, "Hash should not equal plaintext")
|
||||||
|
|
||||||
|
// Bcrypt hashes should start with $2
|
||||||
|
assert.True(t, strings.HasPrefix(hash1, "$2"), "Should be a bcrypt hash")
|
||||||
|
|
||||||
|
// Same key should produce different hashes (due to salt)
|
||||||
|
hash2, err := APIKey(key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEqual(t, hash1, hash2, "Bcrypt should produce different hashes with different salts")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareAPIKey_Bcrypt(t *testing.T) {
|
||||||
|
key := "test_api_key_12345" // gitleaks:allow
|
||||||
|
|
||||||
|
hash, err := APIKey(key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Correct key should match
|
||||||
|
assert.True(t, CompareAPIKey(hash, key))
|
||||||
|
|
||||||
|
// Wrong key should not match
|
||||||
|
assert.False(t, CompareAPIKey(hash, "wrong_key"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareAPIKey_Legacy(t *testing.T) {
|
||||||
|
key := "test_api_key_12345" // gitleaks:allow
|
||||||
|
|
||||||
|
// Create a legacy SHA256 hash
|
||||||
|
legacyHash := String(key)
|
||||||
|
|
||||||
|
// Should still work with legacy hashes
|
||||||
|
assert.True(t, CompareAPIKey(legacyHash, key))
|
||||||
|
|
||||||
|
// Wrong key should not match
|
||||||
|
assert.False(t, CompareAPIKey(legacyHash, "wrong_key"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareAPIKey_BackwardCompatibility(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hashFunc func(string) string
|
||||||
|
expectOK bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "bcrypt hash",
|
||||||
|
hashFunc: func(k string) string {
|
||||||
|
h, _ := APIKey(k)
|
||||||
|
return h
|
||||||
|
},
|
||||||
|
expectOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legacy SHA256 hash",
|
||||||
|
hashFunc: func(k string) string {
|
||||||
|
return String(k)
|
||||||
|
},
|
||||||
|
expectOK: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
key := "test_key_123"
|
||||||
|
hash := tt.hashFunc(key)
|
||||||
|
|
||||||
|
result := CompareAPIKey(hash, key)
|
||||||
|
assert.Equal(t, tt.expectOK, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestString(t *testing.T) {
|
||||||
|
// Test that String function still works (for non-sensitive data)
|
||||||
|
input := "test_string"
|
||||||
|
hash1 := String(input)
|
||||||
|
hash2 := String(input)
|
||||||
|
|
||||||
|
// SHA256 should be deterministic
|
||||||
|
assert.Equal(t, hash1, hash2)
|
||||||
|
assert.NotEmpty(t, hash1)
|
||||||
|
assert.NotEqual(t, input, hash1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsLegacyHash(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hash string
|
||||||
|
isLegacy bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "bcrypt hash",
|
||||||
|
hash: "$2a$12$abcdefghijklmnopqrstuv",
|
||||||
|
isLegacy: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SHA256 hash",
|
||||||
|
hash: "dXNfYWtfMTIzNDU2Nzg5MDEyMzQ1NuOwxEKY",
|
||||||
|
isLegacy: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
hash: "",
|
||||||
|
isLegacy: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.isLegacy, IsLegacyHash(tt.hash))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateAPIKeyHash(t *testing.T) {
|
||||||
|
plainKey := "test_api_key_123"
|
||||||
|
|
||||||
|
t.Run("migrate legacy hash", func(t *testing.T) {
|
||||||
|
// Create a legacy SHA256 hash
|
||||||
|
legacyHash := String(plainKey)
|
||||||
|
|
||||||
|
// Migrate it
|
||||||
|
newHash, migrated, err := MigrateAPIKeyHash(legacyHash, plainKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, migrated, "Should indicate migration occurred")
|
||||||
|
assert.NotEqual(t, legacyHash, newHash, "New hash should differ from legacy")
|
||||||
|
assert.True(t, strings.HasPrefix(newHash, "$2"), "New hash should be bcrypt")
|
||||||
|
|
||||||
|
// Verify new hash works
|
||||||
|
assert.True(t, CompareAPIKey(newHash, plainKey))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no migration needed for bcrypt", func(t *testing.T) {
|
||||||
|
// Create a bcrypt hash
|
||||||
|
bcryptHash, err := APIKey(plainKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Try to migrate it
|
||||||
|
newHash, migrated, err := MigrateAPIKeyHash(bcryptHash, plainKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, migrated, "Should not migrate bcrypt hash")
|
||||||
|
assert.Equal(t, bcryptHash, newHash, "Hash should remain unchanged")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid key does not migrate", func(t *testing.T) {
|
||||||
|
legacyHash := String("correct_key")
|
||||||
|
|
||||||
|
// Try to migrate with wrong plaintext
|
||||||
|
newHash, migrated, err := MigrateAPIKeyHash(legacyHash, "wrong_key")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, migrated, "Should not migrate invalid key")
|
||||||
|
assert.Empty(t, newHash, "Should return empty for invalid key")
|
||||||
|
})
|
||||||
|
}
|
||||||
+2
-3
@@ -9,7 +9,6 @@ import (
|
|||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
"gitlab.com/unboundsoftware/schemas/domain"
|
"gitlab.com/unboundsoftware/schemas/domain"
|
||||||
"gitlab.com/unboundsoftware/schemas/hash"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -49,8 +48,8 @@ func (m *AuthMiddleware) Handler(next http.Handler) http.Handler {
|
|||||||
_, _ = w.Write([]byte("Invalid API Key format"))
|
_, _ = w.Write([]byte("Invalid API Key format"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hashedKey := hash.String(apiKey)
|
// Cache handles hash comparison internally
|
||||||
organization := m.cache.OrganizationByAPIKey(hashedKey)
|
organization := m.cache.OrganizationByAPIKey(apiKey)
|
||||||
if organization != nil {
|
if organization != nil {
|
||||||
ctx = context.WithValue(ctx, OrganizationKey, *organization)
|
ctx = context.WithValue(ctx, OrganizationKey, *organization)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-13
@@ -15,7 +15,6 @@ import (
|
|||||||
"gitlab.com/unboundsoftware/eventsourced/eventsourced"
|
"gitlab.com/unboundsoftware/eventsourced/eventsourced"
|
||||||
|
|
||||||
"gitlab.com/unboundsoftware/schemas/domain"
|
"gitlab.com/unboundsoftware/schemas/domain"
|
||||||
"gitlab.com/unboundsoftware/schemas/hash"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockCache is a mock implementation of the Cache interface
|
// MockCache is a mock implementation of the Cache interface
|
||||||
@@ -45,9 +44,9 @@ func TestAuthMiddleware_Handler_WithValidAPIKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apiKey := "test-api-key-123"
|
apiKey := "test-api-key-123"
|
||||||
hashedKey := hash.String(apiKey)
|
|
||||||
|
|
||||||
mockCache.On("OrganizationByAPIKey", hashedKey).Return(expectedOrg)
|
// Mock expects plaintext key (cache handles hashing internally)
|
||||||
|
mockCache.On("OrganizationByAPIKey", apiKey).Return(expectedOrg)
|
||||||
|
|
||||||
// Create a test handler that checks the context
|
// Create a test handler that checks the context
|
||||||
var capturedOrg *domain.Organization
|
var capturedOrg *domain.Organization
|
||||||
@@ -84,9 +83,9 @@ func TestAuthMiddleware_Handler_WithInvalidAPIKey(t *testing.T) {
|
|||||||
authMiddleware := NewAuth(mockCache)
|
authMiddleware := NewAuth(mockCache)
|
||||||
|
|
||||||
apiKey := "invalid-api-key"
|
apiKey := "invalid-api-key"
|
||||||
hashedKey := hash.String(apiKey)
|
|
||||||
|
|
||||||
mockCache.On("OrganizationByAPIKey", hashedKey).Return(nil)
|
// Mock expects plaintext key (cache handles hashing internally)
|
||||||
|
mockCache.On("OrganizationByAPIKey", apiKey).Return(nil)
|
||||||
|
|
||||||
// Create a test handler that checks the context
|
// Create a test handler that checks the context
|
||||||
var capturedOrg *domain.Organization
|
var capturedOrg *domain.Organization
|
||||||
@@ -120,9 +119,8 @@ func TestAuthMiddleware_Handler_WithoutAPIKey(t *testing.T) {
|
|||||||
mockCache := new(MockCache)
|
mockCache := new(MockCache)
|
||||||
authMiddleware := NewAuth(mockCache)
|
authMiddleware := NewAuth(mockCache)
|
||||||
|
|
||||||
// The middleware always hashes the API key (even if empty) and calls the cache
|
// The middleware passes the plaintext API key (cache handles hashing)
|
||||||
emptyKeyHash := hash.String("")
|
mockCache.On("OrganizationByAPIKey", "").Return(nil)
|
||||||
mockCache.On("OrganizationByAPIKey", emptyKeyHash).Return(nil)
|
|
||||||
|
|
||||||
// Create a test handler that checks the context
|
// Create a test handler that checks the context
|
||||||
var capturedOrg *domain.Organization
|
var capturedOrg *domain.Organization
|
||||||
@@ -153,9 +151,8 @@ func TestAuthMiddleware_Handler_WithValidJWT(t *testing.T) {
|
|||||||
mockCache := new(MockCache)
|
mockCache := new(MockCache)
|
||||||
authMiddleware := NewAuth(mockCache)
|
authMiddleware := NewAuth(mockCache)
|
||||||
|
|
||||||
// The middleware always hashes the API key (even if empty) and calls the cache
|
// The middleware passes the plaintext API key (cache handles hashing)
|
||||||
emptyKeyHash := hash.String("")
|
mockCache.On("OrganizationByAPIKey", "").Return(nil)
|
||||||
mockCache.On("OrganizationByAPIKey", emptyKeyHash).Return(nil)
|
|
||||||
|
|
||||||
userID := "user-123"
|
userID := "user-123"
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
@@ -251,13 +248,13 @@ func TestAuthMiddleware_Handler_BothJWTAndAPIKey(t *testing.T) {
|
|||||||
|
|
||||||
userID := "user-123"
|
userID := "user-123"
|
||||||
apiKey := "test-api-key-123"
|
apiKey := "test-api-key-123"
|
||||||
hashedKey := hash.String(apiKey)
|
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
"sub": userID,
|
"sub": userID,
|
||||||
})
|
})
|
||||||
|
|
||||||
mockCache.On("OrganizationByAPIKey", hashedKey).Return(expectedOrg)
|
// Mock expects plaintext key (cache handles hashing internally)
|
||||||
|
mockCache.On("OrganizationByAPIKey", apiKey).Return(expectedOrg)
|
||||||
|
|
||||||
// Create a test handler that checks both user and organization in context
|
// Create a test handler that checks both user and organization in context
|
||||||
var capturedUser string
|
var capturedUser string
|
||||||
|
|||||||
Reference in New Issue
Block a user