mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 19:20:31 +00:00
162 lines
5.2 KiB
Go
162 lines
5.2 KiB
Go
package security
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"golang.org/x/sync/singleflight"
|
|
|
|
"github.com/bitechdev/ResolveSpec/pkg/cache"
|
|
"github.com/bitechdev/ResolveSpec/pkg/dbtrace"
|
|
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
|
|
"github.com/bitechdev/ResolveSpec/pkg/security/lookup/backends"
|
|
)
|
|
|
|
// DatabaseKeyStoreOptions configures DatabaseKeyStore.
|
|
type DatabaseKeyStoreOptions struct {
|
|
// Cache is an optional cache instance. If nil, uses the default cache.
|
|
Cache *cache.Cache
|
|
// CacheTTL is the duration to cache ValidateKey results.
|
|
// Default: 2 minutes.
|
|
CacheTTL time.Duration
|
|
// Lookup selects dialect, query mode and procedure/table/column names.
|
|
// The zero value uses stored procedures on Postgres and direct SQL elsewhere.
|
|
Lookup lookup.Config
|
|
// LookupProvider, when set, is used instead of building one from Lookup and the db.
|
|
LookupProvider *lookup.Provider
|
|
// DBFactory is called to obtain a fresh *sql.DB when the existing connection is closed.
|
|
// If nil, reconnection is disabled.
|
|
DBFactory func() (*sql.DB, error)
|
|
}
|
|
|
|
// DatabaseKeyStore is a KeyStore backed by the lookup package (stored procedures on
|
|
// Postgres by default, direct SQL elsewhere). The raw key is never passed to the database.
|
|
//
|
|
// See lookup/keystore_schema.sql for the required table and procedure definitions.
|
|
//
|
|
// Note: DeleteKey invalidates the cache entry for the deleted key. Due to the
|
|
// cache TTL, a deleted key may continue to authenticate for up to CacheTTL
|
|
// (default 2 minutes) if the cache entry cannot be invalidated.
|
|
type DatabaseKeyStore struct {
|
|
src *lookupSource
|
|
cache *cache.Cache
|
|
cacheTTL time.Duration
|
|
|
|
// validateLoads collapses concurrent key lookups for the same key
|
|
validateLoads singleflight.Group
|
|
}
|
|
|
|
// NewDatabaseKeyStore creates a DatabaseKeyStore with optional configuration.
|
|
func NewDatabaseKeyStore(db *sql.DB, opts ...DatabaseKeyStoreOptions) *DatabaseKeyStore {
|
|
o := DatabaseKeyStoreOptions{}
|
|
if len(opts) > 0 {
|
|
o = opts[0]
|
|
}
|
|
if o.CacheTTL == 0 {
|
|
o.CacheTTL = 2 * time.Minute
|
|
}
|
|
c := o.Cache
|
|
if c == nil {
|
|
c = cache.GetDefaultCache()
|
|
}
|
|
src := newLookupSource(db)
|
|
src.cfg = o.Lookup
|
|
src.provider = o.LookupProvider
|
|
src.opts = backends.Options{DBFactory: o.DBFactory}
|
|
return &DatabaseKeyStore{src: src, cache: c, cacheTTL: o.CacheTTL}
|
|
}
|
|
|
|
func (ks *DatabaseKeyStore) keys() lookup.KeyStore { return ks.src.get().Keys }
|
|
|
|
// CreateKey generates a raw key, stores its SHA-256 hash via the create procedure,
|
|
// and returns the raw key once.
|
|
func (ks *DatabaseKeyStore) CreateKey(ctx context.Context, req CreateKeyRequest) (*CreateKeyResponse, error) {
|
|
rawBytes := make([]byte, 32)
|
|
if _, err := rand.Read(rawBytes); err != nil {
|
|
return nil, fmt.Errorf("failed to generate key material: %w", err)
|
|
}
|
|
rawKey := base64.RawURLEncoding.EncodeToString(rawBytes)
|
|
hash := hashSHA256Hex(rawKey)
|
|
|
|
key, err := ks.keys().Create(ctx, req, hash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &CreateKeyResponse{Key: *key, RawKey: rawKey}, nil
|
|
}
|
|
|
|
// GetUserKeys returns all active, non-expired keys for the given user.
|
|
// Pass an empty KeyType to return all types.
|
|
func (ks *DatabaseKeyStore) GetUserKeys(ctx context.Context, userID int, keyType KeyType) ([]UserKey, error) {
|
|
return ks.keys().List(ctx, userID, keyType)
|
|
}
|
|
|
|
// DeleteKey soft-deletes a key after verifying ownership and invalidates its cache entry.
|
|
// The delete procedure returns the key_hash so no separate lookup is needed.
|
|
// Note: cache invalidation is best-effort; a cached entry may persist for up to CacheTTL.
|
|
func (ks *DatabaseKeyStore) DeleteKey(ctx context.Context, userID int, keyID int64) error {
|
|
keyHash, err := ks.keys().Delete(ctx, userID, keyID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if keyHash != "" && ks.cache != nil {
|
|
_ = ks.cache.Delete(ctx, keystoreCacheKey(keyHash))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateKey hashes the raw key and calls the validate procedure.
|
|
// Results are cached for CacheTTL to reduce DB load on hot paths.
|
|
func (ks *DatabaseKeyStore) ValidateKey(ctx context.Context, rawKey string, keyType KeyType) (*UserKey, error) {
|
|
hash := hashSHA256Hex(rawKey)
|
|
cacheKey := keystoreCacheKey(hash)
|
|
|
|
if ks.cache != nil {
|
|
var cached UserKey
|
|
if err := ks.cache.Get(ctx, cacheKey, &cached); err == nil {
|
|
if cached.IsActive {
|
|
return &cached, nil
|
|
}
|
|
return nil, errors.New("invalid or expired key")
|
|
}
|
|
}
|
|
|
|
// Concurrent misses for the same key share one database lookup.
|
|
v, err, _ := ks.validateLoads.Do(cacheKey+"|"+string(keyType), func() (any, error) {
|
|
return ks.validateKeyLoad(ctx, hash, cacheKey, keyType)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key, _ := v.(*UserKey)
|
|
if key == nil {
|
|
return nil, errors.New("invalid or expired key")
|
|
}
|
|
cp := *key
|
|
return &cp, nil
|
|
}
|
|
|
|
// validateKeyLoad validates against the database and fills the cache.
|
|
func (ks *DatabaseKeyStore) validateKeyLoad(ctx context.Context, hash, cacheKey string, keyType KeyType) (*UserKey, error) {
|
|
dbtrace.Raw(ctx, "keystore.validate")
|
|
key, err := ks.keys().Validate(ctx, hash, keyType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if ks.cache != nil {
|
|
_ = ks.cache.Set(ctx, cacheKey, *key, ks.cacheTTL)
|
|
}
|
|
|
|
return key, nil
|
|
}
|
|
|
|
func keystoreCacheKey(hash string) string {
|
|
return "keystore:validate:" + hash
|
|
}
|