diff --git a/go.mod b/go.mod index 519610f..e2f9abe 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.warky.dev/wdevs/amcs go 1.26.1 require ( - github.com/bitechdev/ResolveSpec v1.1.24 + github.com/bitechdev/ResolveSpec v1.1.26 github.com/google/jsonschema-go v0.4.3 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.2 diff --git a/go.sum b/go.sum index fad3ebd..80649cc 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bitechdev/ResolveSpec v1.1.24 h1:+Ku3jE8ZSQ2c6IdVyqYp9CdCh4wSasCd1yLDF8GXy5U= -github.com/bitechdev/ResolveSpec v1.1.24/go.mod h1:GF51sMRCWbAyri2WNae3IZAFM/2s6DG6i3eTTrobbVs= +github.com/bitechdev/ResolveSpec v1.1.26 h1:/OYc1Mjcfm4Qxq8Xy5UY/32l5cSqkNe8ieeOqIEbK5o= +github.com/bitechdev/ResolveSpec v1.1.26/go.mod h1:GF51sMRCWbAyri2WNae3IZAFM/2s6DG6i3eTTrobbVs= github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c h1:6Gpm9YYUEQx2T9zMsYolQhr6sjwwGtFitSA0pQsa7a8= github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/resolvespec/security_hooks.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/resolvespec/security_hooks.go index 47b97c5..3040018 100644 --- a/vendor/github.com/bitechdev/ResolveSpec/pkg/resolvespec/security_hooks.go +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/resolvespec/security_hooks.go @@ -78,6 +78,17 @@ func (s *securityContext) GetUserID() (int, bool) { return security.GetUserID(s.ctx.Context) } +// GetUserRef returns an opaque user identifier for row security lookups. +// It prefers the full *security.UserContext (so providers can read JWT claims, +// e.g. a UUID subject) and falls back to the int user ID. +func (s *securityContext) GetUserRef() (any, bool) { + if userCtx, ok := security.GetUserContext(s.ctx.Context); ok { + return userCtx, true + } + userID, ok := security.GetUserID(s.ctx.Context) + return userID, ok +} + func (s *securityContext) GetSchema() string { return s.ctx.Schema } diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/README.md b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/README.md index 5471a3c..0fd0a69 100644 --- a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/README.md +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/README.md @@ -11,7 +11,8 @@ Type-safe, composable security system for ResolveSpec with support for authentic - ✅ **No Global State** - Each handler has its own security configuration - ✅ **Testable** - Easy to mock and test - ✅ **Extensible** - Implement custom providers for your needs -- ✅ **Stored Procedures** - All database operations use PostgreSQL stored procedures for security and maintainability +- ✅ **Stored Procedures** - Database operations use PostgreSQL stored procedures where available, for security and maintainability +- ✅ **Direct Mode** - Portable Go/SQL fallback for SQLite, MySQL, or Postgres without the stored procedures installed — no code changes required - ✅ **OAuth2 Authorization Server** - Built-in OAuth 2.1 + PKCE server (RFC 8414, 7591, 7009, 7662) with login form and external provider federation - ✅ **Password Reset** - Self-service password reset with secure token generation and session invalidation @@ -51,6 +52,94 @@ Type-safe, composable security system for ResolveSpec with support for authentic See `database_schema.sql` for complete stored procedure definitions and examples. +**Not on Postgres, or don't have the procedures installed?** See [Direct Mode](#direct-mode-portable-sql-without-stored-procedures) below — every provider that calls a `resolvespec_*` procedure also has a portable Go/SQL implementation that works on SQLite, MySQL, or plain Postgres. + +## Direct Mode (portable SQL without stored procedures) + +Every database-backed provider (`DatabaseAuthenticator`, `JWTAuthenticator`, `DatabaseTwoFactorProvider`, `DatabasePasskeyProvider`, the OAuth2 methods/server, `DatabaseKeyStore`) has two code paths: + +- **Procedure mode** — calls the configured `resolvespec_*` stored procedure (original behavior, Postgres-only). +- **Direct mode** — reimplements the same logic in Go using plain parameterized SQL against configurable table names. Works on SQLite, MySQL, or a Postgres database where the procedures were never deployed. + +### QueryMode + +Selection is controlled per-provider by a `QueryMode`: + +```go +type QueryMode int + +const ( + ModeAuto QueryMode = iota // default + ModeProcedure + ModeDirect +) +``` + +- **`ModeAuto`** (default, zero value) — auto-detects per connection: + - SQLite/MySQL drivers → Direct mode, no probing. + - Postgres drivers (`lib/pq`, `pgx`) → probes `pg_proc` for the configured procedure name and uses it **only if it actually exists**; otherwise falls back to Direct mode. The result is cached per procedure name and reset on reconnect. + - Any other/unrecognized driver (including `sqlmock` test doubles) → defaults to Procedure mode, preserving existing behavior for callers that don't expose an identifiable driver type. +- **`ModeProcedure`** — always calls the stored procedure, regardless of dialect. +- **`ModeDirect`** — always uses the portable Go/SQL path, never the stored procedure. + +Set it via the provider's `Options` struct or `With...` chain method: + +```go +auth := security.NewDatabaseAuthenticatorWithOptions(db, security.DatabaseAuthenticatorOptions{ + QueryMode: security.ModeDirect, // force Direct mode, e.g. for SQLite +}) + +tfaProvider := security.NewDatabaseTwoFactorProvider(sqliteDB, nil). + WithQueryMode(security.ModeDirect) +``` + +On a real SQLite/MySQL connection you can usually leave `QueryMode` unset — `ModeAuto` detects the dialect and uses Direct mode automatically. + +### TableNames / KeyStoreTableNames + +Direct mode reads/writes plain tables instead of calling procedures, so table names are configurable the same way procedure names are (`SQLNames`): + +```go +type TableNames struct { + Users string // default: "users" + UserSessions string // default: "user_sessions" + TokenBlacklist string // default: "token_blacklist" + UserTOTPBackupCodes string // default: "user_totp_backup_codes" + UserPasskeyCredentials string // default: "user_passkey_credentials" + UserPasswordResets string // default: "user_password_resets" + OAuthClients string // default: "oauth_clients" + OAuthCodes string // default: "oauth_codes" +} + +type KeyStoreTableNames struct { + UserKeys string // default: "user_keys" — used by DatabaseKeyStore +} +``` + +`DefaultTableNames()` / `MergeTableNames()` / `ValidateTableNames()` mirror `DefaultSQLNames()` / `MergeSQLNames()` / `ValidateSQLNames()`. Set custom names via the same `Options`/`With...` surface as `QueryMode`: + +```go +auth := security.NewDatabaseAuthenticatorWithOptions(db, security.DatabaseAuthenticatorOptions{ + TableNames: &security.TableNames{Users: "app_users"}, // only override what differs +}) +``` + +`oauth2_methods.go` and `oauth_server_db.go` are methods on `*DatabaseAuthenticator` and reuse its `TableNames`/`QueryMode`; there's no separate config for them. + +### Schema + +`database_schema_sqlite.sql` is the portable companion to `database_schema.sql` — plain `CREATE TABLE` statements only (no functions, no triggers, no `jsonb`/`bytea`/array types), covering every table Direct mode reads or writes. Use it to stand up a SQLite (or adapt for MySQL) database for Direct mode. + +### What's NOT covered + +`ColumnSecurityProvider`/`RowSecurityProvider` (`resolvespec_column_security` / `resolvespec_row_security`) query an external `core.secaccess`/`core.hub_link` schema this package doesn't own. Direct mode has no portable equivalent to fabricate for these and returns `security.ErrDirectModeUnsupported` — use `ConfigColumnSecurityProvider`/`ConfigRowSecurityProvider` instead when not running against Postgres with those procedures installed. + +### Behavioral notes + +- Direct mode matches Procedure mode's current behavior exactly, including its TODOs — e.g. passwords are compared as-is (the stored procedures don't verify bcrypt hashes yet either; see the TODO in `resolvespec_login`/`resolvespec_password_reset`). +- Session tokens generated by Direct mode use the same `sess__` shape as the plpgsql procedures. +- `bytea`/array/`jsonb` Postgres-only columns (passkey credentials, OAuth2 client scopes, keystore `meta`) are stored as base64/JSON-encoded `TEXT` in Direct mode — transparent to callers, since the Go-level API already deals in those same encodings. + ## Quick Start ```go diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/composite.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/composite.go index 088fe0c..fa5150e 100644 --- a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/composite.go +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/composite.go @@ -74,8 +74,8 @@ func (c *CompositeSecurityProvider) GetColumnSecurity(ctx context.Context, userI } // GetRowSecurity delegates to the row security provider -func (c *CompositeSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) { - return c.rowSec.GetRowSecurity(ctx, userID, schema, table) +func (c *CompositeSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) { + return c.rowSec.GetRowSecurity(ctx, userRef, schema, table) } // Optional interface implementations (if wrapped providers support them) diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/database_schema_sqlite.sql b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/database_schema_sqlite.sql new file mode 100644 index 0000000..32ab3c6 --- /dev/null +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/database_schema_sqlite.sql @@ -0,0 +1,141 @@ +-- Portable schema for Direct-mode (non-stored-procedure) operation. +-- Plain CREATE TABLE statements only, no functions/triggers, using types +-- understood by SQLite (and portable to MySQL). Used by Direct-mode tests +-- and as a reference for deployments without Postgres. + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(255) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(255), + user_level INTEGER DEFAULT 0, + roles VARCHAR(500), + is_active BOOLEAN DEFAULT 1, + created_at TIMESTAMP, + updated_at TIMESTAMP, + last_login_at TIMESTAMP, + program_user_id INTEGER DEFAULT 0, + program_user_table VARCHAR(255) DEFAULT '', + remote_id VARCHAR(255), + auth_provider VARCHAR(50), + totp_secret VARCHAR(255), + totp_enabled BOOLEAN DEFAULT 0, + totp_enabled_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS user_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_token VARCHAR(500) NOT NULL UNIQUE, + user_id INTEGER NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP, + last_activity_at TIMESTAMP, + ip_address VARCHAR(45), + user_agent TEXT, + access_token TEXT, + refresh_token TEXT, + token_type VARCHAR(50) DEFAULT 'Bearer', + auth_provider VARCHAR(50) +); + +CREATE INDEX IF NOT EXISTS idx_session_token ON user_sessions(session_token); +CREATE INDEX IF NOT EXISTS idx_user_id ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_expires_at ON user_sessions(expires_at); +CREATE INDEX IF NOT EXISTS idx_refresh_token ON user_sessions(refresh_token); + +CREATE TABLE IF NOT EXISTS token_blacklist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token VARCHAR(500) NOT NULL, + user_id INTEGER, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS user_totp_backup_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + code_hash VARCHAR(64) NOT NULL, + used BOOLEAN DEFAULT 0, + used_at TIMESTAMP, + created_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_totp_user_id ON user_totp_backup_codes(user_id); +CREATE INDEX IF NOT EXISTS idx_totp_code_hash ON user_totp_backup_codes(code_hash); + +CREATE TABLE IF NOT EXISTS user_passkey_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + credential_id TEXT NOT NULL UNIQUE, -- base64 text (Direct mode), not native bytea + public_key TEXT NOT NULL, -- base64 text + attestation_type VARCHAR(50) DEFAULT 'none', + aaguid TEXT, -- base64 text + sign_count INTEGER DEFAULT 0, + clone_warning BOOLEAN DEFAULT 0, + transports TEXT, -- JSON-encoded []string + backup_eligible BOOLEAN DEFAULT 0, + backup_state BOOLEAN DEFAULT 0, + name VARCHAR(255), + created_at TIMESTAMP, + last_used_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_passkey_user_id ON user_passkey_credentials(user_id); +CREATE INDEX IF NOT EXISTS idx_passkey_credential_id ON user_passkey_credentials(credential_id); + +CREATE TABLE IF NOT EXISTS user_password_resets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token_hash VARCHAR(64) NOT NULL UNIQUE, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP, + used BOOLEAN DEFAULT 0, + used_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS oauth_clients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id VARCHAR(255) NOT NULL UNIQUE, + redirect_uris TEXT NOT NULL, -- JSON-encoded []string + client_name VARCHAR(255), + grant_types TEXT, -- JSON-encoded []string + allowed_scopes TEXT, -- JSON-encoded []string + is_active BOOLEAN DEFAULT 1, + created_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS oauth_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code VARCHAR(255) NOT NULL UNIQUE, + client_id VARCHAR(255) NOT NULL, + redirect_uri TEXT NOT NULL, + client_state TEXT, + code_challenge VARCHAR(255) NOT NULL, + code_challenge_method VARCHAR(10) DEFAULT 'S256', + session_token TEXT NOT NULL, + refresh_token TEXT, + scopes TEXT, -- JSON-encoded []string + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_oauth_codes_code ON oauth_codes(code); +CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_codes(expires_at); + +CREATE TABLE IF NOT EXISTS user_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + key_type VARCHAR(50) NOT NULL, + key_hash VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL DEFAULT '', + scopes TEXT, -- JSON-encoded []string + meta TEXT, -- JSON-encoded map + expires_at TIMESTAMP, + created_at TIMESTAMP, + last_used_at TIMESTAMP, + is_active BOOLEAN DEFAULT 1 +); + +CREATE INDEX IF NOT EXISTS idx_user_keys_user_id ON user_keys(user_id); +CREATE INDEX IF NOT EXISTS idx_user_keys_key_hash ON user_keys(key_hash); +CREATE INDEX IF NOT EXISTS idx_user_keys_key_type ON user_keys(key_type); diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/hooks.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/hooks.go index 52138d8..7628914 100644 --- a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/hooks.go +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/hooks.go @@ -14,6 +14,11 @@ import ( type SecurityContext interface { GetContext() context.Context GetUserID() (int, bool) + // GetUserRef returns an opaque user identifier for row security lookups. + // Unlike GetUserID, it is not required to be an integer: implementations backed by + // non-integer identifiers (e.g. UUIDs) can return a string, or the full + // *security.UserContext so a RowSecurityProvider can read JWT claims directly. + GetUserRef() (any, bool) GetSchema() string GetEntity() string GetModel() interface{} @@ -45,8 +50,13 @@ func loadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error // return err } - // Load row security rules using the provider - _, err = securityList.LoadRowSecurity(secCtx.GetContext(), userID, schema, tablename, false) + // Load row security rules using the provider. Row security uses the opaque + // user ref (not the int-only user ID) so non-integer user identifiers work. + userRef, refOK := secCtx.GetUserRef() + if !refOK { + userRef = userID + } + _, err = securityList.LoadRowSecurity(secCtx.GetContext(), userRef, schema, tablename, false) if err != nil { logger.Warn("Failed to load row security: %v", err) // Don't fail the request if no security rules exist @@ -58,25 +68,29 @@ func loadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error // applyRowSecurity applies row-level security filters to the query (generic version) func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error { - userID, ok := secCtx.GetUserID() + userRef, ok := secCtx.GetUserRef() if !ok { - return nil // No user context, skip + userID, idOK := secCtx.GetUserID() + if !idOK { + return nil // No user context, skip + } + userRef = userID } schema := secCtx.GetSchema() tablename := secCtx.GetEntity() // Get row security template - rowSec, err := securityList.GetRowSecurityTemplate(userID, schema, tablename) + rowSec, err := securityList.GetRowSecurityTemplate(userRef, schema, tablename) if err != nil { // No row security defined, allow query to proceed - logger.Debug("No row security for %s.%s@%d: %v", schema, tablename, userID, err) + logger.Debug("No row security for %s.%s@%v: %v", schema, tablename, userRef, err) return nil } // Check if user has a blocking rule if rowSec.HasBlock { - logger.Warn("User %d blocked from accessing %s.%s", userID, schema, tablename) + logger.Warn("User %v blocked from accessing %s.%s", userRef, schema, tablename) return fmt.Errorf("access denied to %s", tablename) } @@ -112,8 +126,8 @@ func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error // Generate the WHERE clause from template whereClause := rowSec.GetTemplate(pkName, modelType) - logger.Info("Applying row security filter for user %d on %s.%s: %s", - userID, schema, tablename, whereClause) + logger.Info("Applying row security filter for user %v on %s.%s: %s", + userRef, schema, tablename, whereClause) // Apply the WHERE clause to the query query := secCtx.GetQuery() diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/interfaces.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/interfaces.go index 1fc701a..1e2c18a 100644 --- a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/interfaces.go +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/interfaces.go @@ -121,8 +121,12 @@ type ColumnSecurityProvider interface { // RowSecurityProvider handles row-level security (filtering) type RowSecurityProvider interface { - // GetRowSecurity loads row security rules for a user and entity - GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) + // GetRowSecurity loads row security rules for a user and entity. + // userRef identifies the user and is opaque to the caller: it may be an int ID, + // a string/UUID, or the full *security.UserContext (see SecurityContext.GetUserRef), + // so providers backed by non-integer user identifiers (e.g. UUIDs) or that need + // access to JWT claims can implement row security without relying on a numeric ID. + GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) } // SecurityProvider is the main interface combining all security concerns diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/keystore_database.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/keystore_database.go index 75e7eb1..70d250a 100644 --- a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/keystore_database.go +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/keystore_database.go @@ -23,6 +23,10 @@ type DatabaseKeyStoreOptions struct { CacheTTL time.Duration // SQLNames provides custom procedure names. If nil, uses DefaultKeyStoreSQLNames(). SQLNames *KeyStoreSQLNames + // TableNames provides custom table names for Direct mode. If nil, uses DefaultKeyStoreTableNames(). + TableNames *KeyStoreTableNames + // QueryMode selects stored-procedure vs Direct-mode SQL. Default (zero value) is ModeAuto. + QueryMode QueryMode // 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) @@ -38,12 +42,15 @@ type DatabaseKeyStoreOptions struct { // 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 { - db *sql.DB - dbMu sync.RWMutex - dbFactory func() (*sql.DB, error) - sqlNames *KeyStoreSQLNames - cache *cache.Cache - cacheTTL time.Duration + db *sql.DB + dbMu sync.RWMutex + dbFactory func() (*sql.DB, error) + sqlNames *KeyStoreSQLNames + tableNames *KeyStoreTableNames + queryMode QueryMode + capability *dbCapability + cache *cache.Cache + cacheTTL time.Duration } // NewDatabaseKeyStore creates a DatabaseKeyStore with optional configuration. @@ -60,12 +67,16 @@ func NewDatabaseKeyStore(db *sql.DB, opts ...DatabaseKeyStoreOptions) *DatabaseK c = cache.GetDefaultCache() } names := MergeKeyStoreSQLNames(DefaultKeyStoreSQLNames(), o.SQLNames) + tableNames := resolveKeyStoreTableNames(o.TableNames) return &DatabaseKeyStore{ - db: db, - dbFactory: o.DBFactory, - sqlNames: names, - cache: c, - cacheTTL: o.CacheTTL, + db: db, + dbFactory: o.DBFactory, + sqlNames: names, + tableNames: tableNames, + queryMode: o.QueryMode, + capability: newDBCapability(), + cache: c, + cacheTTL: o.CacheTTL, } } @@ -86,6 +97,9 @@ func (ks *DatabaseKeyStore) reconnectDB() error { ks.dbMu.Lock() ks.db = newDB ks.dbMu.Unlock() + if ks.capability != nil { + ks.capability.reset() + } return nil } @@ -99,6 +113,14 @@ func (ks *DatabaseKeyStore) CreateKey(ctx context.Context, req CreateKeyRequest) rawKey := base64.RawURLEncoding.EncodeToString(rawBytes) hash := hashSHA256Hex(rawKey) + if !ks.capability.ShouldUseProcedure(ctx, ks.queryMode, ks.getDB(), ks.sqlNames.CreateKey) { + key, err := ks.createKeyDirect(ctx, req, hash) + if err != nil { + return nil, err + } + return &CreateKeyResponse{Key: *key, RawKey: rawKey}, nil + } + type createRequest struct { UserID int `json:"user_id"` KeyType KeyType `json:"key_type"` @@ -145,6 +167,10 @@ func (ks *DatabaseKeyStore) CreateKey(ctx context.Context, req CreateKeyRequest) // 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) { + if !ks.capability.ShouldUseProcedure(ctx, ks.queryMode, ks.getDB(), ks.sqlNames.GetUserKeys) { + return ks.getUserKeysDirect(ctx, userID, keyType) + } + var success bool var errorMsg sql.NullString var keysJSON sql.NullString @@ -173,6 +199,10 @@ func (ks *DatabaseKeyStore) GetUserKeys(ctx context.Context, userID int, keyType // 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 { + if !ks.capability.ShouldUseProcedure(ctx, ks.queryMode, ks.getDB(), ks.sqlNames.DeleteKey) { + return ks.deleteKeyDirect(ctx, userID, keyID) + } + var success bool var errorMsg sql.NullString var keyHash sql.NullString @@ -207,6 +237,17 @@ func (ks *DatabaseKeyStore) ValidateKey(ctx context.Context, rawKey string, keyT } } + if !ks.capability.ShouldUseProcedure(ctx, ks.queryMode, ks.getDB(), ks.sqlNames.ValidateKey) { + key, err := ks.validateKeyDirect(ctx, hash, keyType) + if err != nil { + return nil, err + } + if ks.cache != nil { + _ = ks.cache.Set(ctx, cacheKey, *key, ks.cacheTTL) + } + return key, nil + } + var success bool var errorMsg sql.NullString var keyJSON sql.NullString diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/keystore_database_direct.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/keystore_database_direct.go new file mode 100644 index 0000000..af444e7 --- /dev/null +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/keystore_database_direct.go @@ -0,0 +1,216 @@ +package security + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" +) + +// Direct-mode implementations mirroring the resolvespec_keystore_* stored +// procedures in keystore_schema.sql using plain SQL against +// TableNames.UserKeys. meta/scopes are stored as JSON-encoded TEXT instead +// of Postgres JSONB. + +func (ks *DatabaseKeyStore) createKeyDirect(ctx context.Context, req CreateKeyRequest, keyHash string) (*UserKey, error) { + scopesJSON, err := json.Marshal(req.Scopes) + if err != nil { + return nil, fmt.Errorf("failed to marshal scopes: %w", err) + } + var metaJSON []byte + if req.Meta != nil { + metaJSON, err = json.Marshal(req.Meta) + if err != nil { + return nil, fmt.Errorf("failed to marshal meta: %w", err) + } + } + + now := time.Now() + var id int64 + err = ks.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf( + `INSERT INTO %s (user_id, key_type, key_hash, name, scopes, meta, expires_at, created_at, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ks.tableNames.UserKeys)) + res, err := db.ExecContext(ctx, query, req.UserID, string(req.KeyType), keyHash, req.Name, string(scopesJSON), nullableString(metaJSON), req.ExpiresAt, now, true) + if err != nil { + return err + } + id, err = res.LastInsertId() + return err + }) + if err != nil { + return nil, fmt.Errorf("create key query failed: %w", err) + } + + return &UserKey{ + ID: id, + UserID: req.UserID, + KeyType: req.KeyType, + KeyHash: keyHash, + Name: req.Name, + Scopes: req.Scopes, + Meta: req.Meta, + ExpiresAt: req.ExpiresAt, + CreatedAt: now, + IsActive: true, + }, nil +} + +func (ks *DatabaseKeyStore) runDBOpWithReconnect(run func(*sql.DB) error) error { + db := ks.getDB() + if db == nil { + return fmt.Errorf("database connection is nil") + } + err := run(db) + if isDBClosed(err) { + if reconnErr := ks.reconnectDB(); reconnErr == nil { + err = run(ks.getDB()) + } + } + return err +} + +func (ks *DatabaseKeyStore) getUserKeysDirect(ctx context.Context, userID int, keyType KeyType) ([]UserKey, error) { + keys := []UserKey{} + err := ks.runDBOpWithReconnect(func(db *sql.DB) error { + var query string + var args []any + if keyType == "" { + query = rewritePlaceholders(db, fmt.Sprintf( + `SELECT id, user_id, key_type, name, scopes, meta, expires_at, created_at, last_used_at, is_active + FROM %s WHERE user_id = ? AND is_active = ? AND (expires_at IS NULL OR expires_at > ?)`, ks.tableNames.UserKeys)) + args = []any{userID, true, time.Now()} + } else { + query = rewritePlaceholders(db, fmt.Sprintf( + `SELECT id, user_id, key_type, name, scopes, meta, expires_at, created_at, last_used_at, is_active + FROM %s WHERE user_id = ? AND is_active = ? AND (expires_at IS NULL OR expires_at > ?) AND key_type = ?`, ks.tableNames.UserKeys)) + args = []any{userID, true, time.Now(), string(keyType)} + } + + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var k UserKey + var kt string + var scopesJSON, metaJSON sql.NullString + var expiresAt, lastUsedAt sql.NullTime + if err := rows.Scan(&k.ID, &k.UserID, &kt, &k.Name, &scopesJSON, &metaJSON, &expiresAt, &k.CreatedAt, &lastUsedAt, &k.IsActive); err != nil { + return err + } + k.KeyType = KeyType(kt) + if scopesJSON.Valid && scopesJSON.String != "" { + _ = json.Unmarshal([]byte(scopesJSON.String), &k.Scopes) + } + if metaJSON.Valid && metaJSON.String != "" { + _ = json.Unmarshal([]byte(metaJSON.String), &k.Meta) + } + if expiresAt.Valid { + t := expiresAt.Time + k.ExpiresAt = &t + } + if lastUsedAt.Valid { + t := lastUsedAt.Time + k.LastUsedAt = &t + } + keys = append(keys, k) + } + return rows.Err() + }) + if err != nil { + return nil, fmt.Errorf("get user keys query failed: %w", err) + } + return keys, nil +} + +func (ks *DatabaseKeyStore) deleteKeyDirect(ctx context.Context, userID int, keyID int64) error { + var keyHash string + err := ks.runDBOpWithReconnect(func(db *sql.DB) error { + selQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT key_hash FROM %s WHERE id = ? AND user_id = ? AND is_active = ?`, ks.tableNames.UserKeys)) + if err := db.QueryRowContext(ctx, selQuery, keyID, userID, true).Scan(&keyHash); err != nil { + return err + } + updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET is_active = ? WHERE id = ? AND user_id = ? AND is_active = ?`, ks.tableNames.UserKeys)) + _, err := db.ExecContext(ctx, updQuery, false, keyID, userID, true) + return err + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return errors.New("key not found or already deleted") + } + return fmt.Errorf("delete key query failed: %w", err) + } + + if keyHash != "" && ks.cache != nil { + _ = ks.cache.Delete(ctx, keystoreCacheKey(keyHash)) + } + return nil +} + +func (ks *DatabaseKeyStore) validateKeyDirect(ctx context.Context, keyHash string, keyType KeyType) (*UserKey, error) { + var k UserKey + var kt string + var scopesJSON, metaJSON sql.NullString + var expiresAt, lastUsedAt sql.NullTime + + err := ks.runDBOpWithReconnect(func(db *sql.DB) error { + var query string + var args []any + if keyType == "" { + query = rewritePlaceholders(db, fmt.Sprintf( + `SELECT id, user_id, key_type, name, scopes, meta, expires_at, created_at, is_active + FROM %s WHERE key_hash = ? AND is_active = ? AND (expires_at IS NULL OR expires_at > ?)`, ks.tableNames.UserKeys)) + args = []any{keyHash, true, time.Now()} + } else { + query = rewritePlaceholders(db, fmt.Sprintf( + `SELECT id, user_id, key_type, name, scopes, meta, expires_at, created_at, is_active + FROM %s WHERE key_hash = ? AND is_active = ? AND (expires_at IS NULL OR expires_at > ?) AND key_type = ?`, ks.tableNames.UserKeys)) + args = []any{keyHash, true, time.Now(), string(keyType)} + } + + if err := db.QueryRowContext(ctx, query, args...).Scan(&k.ID, &k.UserID, &kt, &k.Name, &scopesJSON, &metaJSON, &expiresAt, &k.CreatedAt, &k.IsActive); err != nil { + return err + } + + now := time.Now() + updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET last_used_at = ? WHERE id = ?`, ks.tableNames.UserKeys)) + _, err := db.ExecContext(ctx, updQuery, now, k.ID) + return err + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, errors.New("invalid or expired key") + } + return nil, fmt.Errorf("validate key query failed: %w", err) + } + + k.KeyType = KeyType(kt) + k.KeyHash = keyHash + if scopesJSON.Valid && scopesJSON.String != "" { + _ = json.Unmarshal([]byte(scopesJSON.String), &k.Scopes) + } + if metaJSON.Valid && metaJSON.String != "" { + _ = json.Unmarshal([]byte(metaJSON.String), &k.Meta) + } + if expiresAt.Valid { + t := expiresAt.Time + k.ExpiresAt = &t + } + _ = lastUsedAt + now := time.Now() + k.LastUsedAt = &now + + return &k, nil +} + +func nullableString(b []byte) any { + if b == nil { + return nil + } + return string(b) +} diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/keystore_table_names.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/keystore_table_names.go new file mode 100644 index 0000000..2ecc761 --- /dev/null +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/keystore_table_names.go @@ -0,0 +1,44 @@ +package security + +import "fmt" + +// KeyStoreTableNames holds the configurable table name used by DatabaseKeyStore +// in Direct mode. Use DefaultKeyStoreTableNames() for defaults and +// MergeKeyStoreTableNames() for partial overrides. +type KeyStoreTableNames struct { + UserKeys string // default: "user_keys" +} + +// DefaultKeyStoreTableNames returns a KeyStoreTableNames with default table names. +func DefaultKeyStoreTableNames() *KeyStoreTableNames { + return &KeyStoreTableNames{ + UserKeys: "user_keys", + } +} + +// MergeKeyStoreTableNames returns a copy of base with any non-empty fields from override applied. +// If override is nil, a copy of base is returned. +func MergeKeyStoreTableNames(base, override *KeyStoreTableNames) *KeyStoreTableNames { + if override == nil { + copied := *base + return &copied + } + merged := *base + if override.UserKeys != "" { + merged.UserKeys = override.UserKeys + } + return &merged +} + +// ValidateKeyStoreTableNames checks that all non-empty table names are valid SQL identifiers. +func ValidateKeyStoreTableNames(names *KeyStoreTableNames) error { + if names.UserKeys != "" && !validSQLIdentifier.MatchString(names.UserKeys) { + return fmt.Errorf("KeyStoreTableNames.UserKeys contains invalid characters: %q", names.UserKeys) + } + return nil +} + +// resolveKeyStoreTableNames merges an optional override with defaults. +func resolveKeyStoreTableNames(override *KeyStoreTableNames) *KeyStoreTableNames { + return MergeKeyStoreTableNames(DefaultKeyStoreTableNames(), override) +} diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/oauth2_methods.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/oauth2_methods.go index 79a58de..2ca859f 100644 --- a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/oauth2_methods.go +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/oauth2_methods.go @@ -226,6 +226,10 @@ func (a *DatabaseAuthenticator) getOAuth2Provider(providerName string) (*OAuth2P // oauth2GetOrCreateUser finds or creates a user based on OAuth2 info using stored procedure func (a *DatabaseAuthenticator) oauth2GetOrCreateUser(ctx context.Context, userCtx *UserContext, providerName string) (int, error) { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthGetOrCreateUser) { + return a.oauth2GetOrCreateUserDirect(ctx, userCtx, providerName) + } + userData := map[string]interface{}{ "username": userCtx.UserName, "email": userCtx.Email, @@ -269,6 +273,10 @@ func (a *DatabaseAuthenticator) oauth2GetOrCreateUser(ctx context.Context, userC // oauth2CreateSession creates a new OAuth2 session using stored procedure func (a *DatabaseAuthenticator) oauth2CreateSession(ctx context.Context, sessionToken string, userID int, token *oauth2.Token, expiresAt time.Time, providerName string) error { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthCreateSession) { + return a.oauth2CreateSessionDirect(ctx, sessionToken, userID, token, expiresAt, providerName) + } + sessionData := map[string]interface{}{ "session_token": sessionToken, "user_id": userID, @@ -381,35 +389,9 @@ func (a *DatabaseAuthenticator) OAuth2RefreshToken(ctx context.Context, refreshT } // Get session by refresh token from database - var success bool - var errMsg *string - var sessionData []byte - - err = a.getDB().QueryRowContext(ctx, fmt.Sprintf(` - SELECT p_success, p_error, p_data::text - FROM %s($1) - `, a.sqlNames.OAuthGetRefreshToken), refreshToken).Scan(&success, &errMsg, &sessionData) - + session, err := a.oauthGetByRefreshToken(ctx, refreshToken) if err != nil { - return nil, fmt.Errorf("failed to get session by refresh token: %w", err) - } - - if !success { - if errMsg != nil { - return nil, fmt.Errorf("%s", *errMsg) - } - return nil, fmt.Errorf("invalid or expired refresh token") - } - - // Parse session data - var session struct { - UserID int `json:"user_id"` - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - Expiry time.Time `json:"expiry"` - } - if err := json.Unmarshal(sessionData, &session); err != nil { - return nil, fmt.Errorf("failed to parse session data: %w", err) + return nil, err } // Create oauth2.Token from stored data @@ -434,64 +416,14 @@ func (a *DatabaseAuthenticator) OAuth2RefreshToken(ctx context.Context, refreshT } // Update session in database with new tokens - updateData := map[string]interface{}{ - "user_id": session.UserID, - "old_refresh_token": refreshToken, - "new_session_token": newSessionToken, - "new_access_token": newToken.AccessToken, - "new_refresh_token": newToken.RefreshToken, - "expires_at": newToken.Expiry, - } - - updateJSON, err := json.Marshal(updateData) - if err != nil { - return nil, fmt.Errorf("failed to marshal update data: %w", err) - } - - var updateSuccess bool - var updateErrMsg *string - - err = a.getDB().QueryRowContext(ctx, fmt.Sprintf(` - SELECT p_success, p_error - FROM %s($1::jsonb) - `, a.sqlNames.OAuthUpdateRefreshToken), updateJSON).Scan(&updateSuccess, &updateErrMsg) - - if err != nil { - return nil, fmt.Errorf("failed to update session: %w", err) - } - - if !updateSuccess { - if updateErrMsg != nil { - return nil, fmt.Errorf("%s", *updateErrMsg) - } - return nil, fmt.Errorf("failed to update session") + if err := a.oauthUpdateRefreshTokenRecord(ctx, session.UserID, refreshToken, newSessionToken, newToken.AccessToken, newToken.RefreshToken, newToken.Expiry); err != nil { + return nil, err } // Get user data - var userSuccess bool - var userErrMsg *string - var userData []byte - - err = a.getDB().QueryRowContext(ctx, fmt.Sprintf(` - SELECT p_success, p_error, p_data::text - FROM %s($1) - `, a.sqlNames.OAuthGetUser), session.UserID).Scan(&userSuccess, &userErrMsg, &userData) - + userCtx, err := a.oauthGetUserByID(ctx, session.UserID) if err != nil { - return nil, fmt.Errorf("failed to get user data: %w", err) - } - - if !userSuccess { - if userErrMsg != nil { - return nil, fmt.Errorf("%s", *userErrMsg) - } - return nil, fmt.Errorf("failed to get user data") - } - - // Parse user context - var userCtx UserContext - if err := json.Unmarshal(userData, &userCtx); err != nil { - return nil, fmt.Errorf("failed to parse user context: %w", err) + return nil, err } userCtx.SessionID = newSessionToken @@ -499,7 +431,7 @@ func (a *DatabaseAuthenticator) OAuth2RefreshToken(ctx context.Context, refreshT return &LoginResponse{ Token: newSessionToken, RefreshToken: newToken.RefreshToken, - User: &userCtx, + User: userCtx, ExpiresIn: int64(time.Until(newToken.Expiry).Seconds()), }, nil } diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/oauth2_methods_direct.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/oauth2_methods_direct.go new file mode 100644 index 0000000..abfc448 --- /dev/null +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/oauth2_methods_direct.go @@ -0,0 +1,242 @@ +package security + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "golang.org/x/oauth2" +) + +// oauthRefreshSession is the session data needed to refresh an OAuth2 token, +// shared by both the stored-procedure and Direct-mode code paths. +type oauthRefreshSession struct { + UserID int `json:"user_id"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Expiry time.Time `json:"expiry"` +} + +// oauth2GetOrCreateUserDirect mirrors resolvespec_oauth_getorcreateuser. +func (a *DatabaseAuthenticator) oauth2GetOrCreateUserDirect(ctx context.Context, userCtx *UserContext, providerName string) (int, error) { + rolesStr := strings.Join(userCtx.Roles, ",") + var userID int + + err := a.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf(`SELECT id FROM %s WHERE email = ?`, a.tableNames.Users)) + err := db.QueryRowContext(ctx, query, userCtx.Email).Scan(&userID) + if err == nil { + now := time.Now() + updQuery := rewritePlaceholders(db, fmt.Sprintf( + `UPDATE %s SET last_login_at = ?, updated_at = ?, remote_id = COALESCE(remote_id, ?), auth_provider = COALESCE(auth_provider, ?) WHERE id = ?`, + a.tableNames.Users)) + _, err := db.ExecContext(ctx, updQuery, now, now, userCtx.RemoteID, providerName, userID) + return err + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + + now := time.Now() + insQuery := rewritePlaceholders(db, fmt.Sprintf( + `INSERT INTO %s (username, email, password, user_level, roles, is_active, created_at, updated_at, last_login_at, remote_id, auth_provider) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`, + a.tableNames.Users)) + res, err := db.ExecContext(ctx, insQuery, userCtx.UserName, userCtx.Email, userCtx.UserLevel, rolesStr, true, now, now, now, userCtx.RemoteID, providerName) + if err != nil { + return err + } + id, err := res.LastInsertId() + if err != nil { + return err + } + userID = int(id) + return nil + }) + if err != nil { + return 0, fmt.Errorf("failed to get or create user: %w", err) + } + return userID, nil +} + +// oauth2CreateSessionDirect mirrors resolvespec_oauth_createsession (insert-or-update by session_token). +func (a *DatabaseAuthenticator) oauth2CreateSessionDirect(ctx context.Context, sessionToken string, userID int, token *oauth2.Token, expiresAt time.Time, providerName string) error { + return a.runDBOpWithReconnect(func(db *sql.DB) error { + var exists int + checkQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT 1 FROM %s WHERE session_token = ?`, a.tableNames.UserSessions)) + err := db.QueryRowContext(ctx, checkQuery, sessionToken).Scan(&exists) + now := time.Now() + if err == nil { + updQuery := rewritePlaceholders(db, fmt.Sprintf( + `UPDATE %s SET access_token = ?, refresh_token = ?, token_type = ?, expires_at = ?, last_activity_at = ? WHERE session_token = ?`, + a.tableNames.UserSessions)) + _, err := db.ExecContext(ctx, updQuery, token.AccessToken, token.RefreshToken, token.TokenType, expiresAt, now, sessionToken) + return err + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + + insQuery := rewritePlaceholders(db, fmt.Sprintf( + `INSERT INTO %s (session_token, user_id, expires_at, created_at, last_activity_at, access_token, refresh_token, token_type, auth_provider) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + a.tableNames.UserSessions)) + _, err = db.ExecContext(ctx, insQuery, sessionToken, userID, expiresAt, now, now, token.AccessToken, token.RefreshToken, token.TokenType, providerName) + return err + }) +} + +// oauthGetByRefreshToken retrieves the session for a refresh token, dispatching between +// the resolvespec_oauth_getrefreshtoken stored procedure and Direct-mode SQL. +func (a *DatabaseAuthenticator) oauthGetByRefreshToken(ctx context.Context, refreshToken string) (*oauthRefreshSession, error) { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthGetRefreshToken) { + var session oauthRefreshSession + err := a.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf( + `SELECT user_id, access_token, token_type, expires_at FROM %s WHERE refresh_token = ? AND expires_at > ?`, + a.tableNames.UserSessions)) + return db.QueryRowContext(ctx, query, refreshToken, time.Now()).Scan(&session.UserID, &session.AccessToken, &session.TokenType, &session.Expiry) + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("refresh token not found or expired") + } + return nil, fmt.Errorf("failed to get session by refresh token: %w", err) + } + return &session, nil + } + + var success bool + var errMsg *string + var sessionData []byte + + err := a.getDB().QueryRowContext(ctx, fmt.Sprintf(` + SELECT p_success, p_error, p_data::text + FROM %s($1) + `, a.sqlNames.OAuthGetRefreshToken), refreshToken).Scan(&success, &errMsg, &sessionData) + if err != nil { + return nil, fmt.Errorf("failed to get session by refresh token: %w", err) + } + if !success { + if errMsg != nil { + return nil, fmt.Errorf("%s", *errMsg) + } + return nil, fmt.Errorf("invalid or expired refresh token") + } + + var session oauthRefreshSession + if err := json.Unmarshal(sessionData, &session); err != nil { + return nil, fmt.Errorf("failed to parse session data: %w", err) + } + return &session, nil +} + +// oauthUpdateRefreshTokenRecord updates a session with new tokens, dispatching between +// the resolvespec_oauth_updaterefreshtoken stored procedure and Direct-mode SQL. +func (a *DatabaseAuthenticator) oauthUpdateRefreshTokenRecord(ctx context.Context, userID int, oldRefreshToken, newSessionToken, newAccessToken, newRefreshToken string, expiresAt time.Time) error { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthUpdateRefreshToken) { + var rows int64 + err := a.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf( + `UPDATE %s SET session_token = ?, access_token = ?, refresh_token = ?, expires_at = ?, last_activity_at = ? WHERE user_id = ? AND refresh_token = ?`, + a.tableNames.UserSessions)) + res, err := db.ExecContext(ctx, query, newSessionToken, newAccessToken, newRefreshToken, expiresAt, time.Now(), userID, oldRefreshToken) + if err != nil { + return err + } + rows, err = res.RowsAffected() + return err + }) + if err != nil { + return fmt.Errorf("failed to update session: %w", err) + } + if rows == 0 { + return fmt.Errorf("session not found") + } + return nil + } + + updateData := map[string]interface{}{ + "user_id": userID, + "old_refresh_token": oldRefreshToken, + "new_session_token": newSessionToken, + "new_access_token": newAccessToken, + "new_refresh_token": newRefreshToken, + "expires_at": expiresAt, + } + updateJSON, err := json.Marshal(updateData) + if err != nil { + return fmt.Errorf("failed to marshal update data: %w", err) + } + + var updateSuccess bool + var updateErrMsg *string + err = a.getDB().QueryRowContext(ctx, fmt.Sprintf(` + SELECT p_success, p_error + FROM %s($1::jsonb) + `, a.sqlNames.OAuthUpdateRefreshToken), updateJSON).Scan(&updateSuccess, &updateErrMsg) + if err != nil { + return fmt.Errorf("failed to update session: %w", err) + } + if !updateSuccess { + if updateErrMsg != nil { + return fmt.Errorf("%s", *updateErrMsg) + } + return fmt.Errorf("failed to update session") + } + return nil +} + +// oauthGetUserByID retrieves user data by ID, dispatching between the +// resolvespec_oauth_getuser stored procedure and Direct-mode SQL. +func (a *DatabaseAuthenticator) oauthGetUserByID(ctx context.Context, userID int) (*UserContext, error) { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthGetUser) { + var username, email, roles, programUserTable sql.NullString + var userLevel, programUserID sql.NullInt64 + err := a.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf( + `SELECT username, email, user_level, roles, program_user_id, program_user_table FROM %s WHERE id = ? AND is_active = ?`, + a.tableNames.Users)) + return db.QueryRowContext(ctx, query, userID, true).Scan(&username, &email, &userLevel, &roles, &programUserID, &programUserTable) + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("user not found") + } + return nil, fmt.Errorf("failed to get user data: %w", err) + } + return &UserContext{ + UserID: userID, + UserName: username.String, + Email: email.String, + UserLevel: int(userLevel.Int64), + Roles: parseRoles(roles.String), + ProgramUserID: int(programUserID.Int64), + ProgramUserTable: programUserTable.String, + }, nil + } + + var userSuccess bool + var userErrMsg *string + var userData []byte + err := a.getDB().QueryRowContext(ctx, fmt.Sprintf(` + SELECT p_success, p_error, p_data::text + FROM %s($1) + `, a.sqlNames.OAuthGetUser), userID).Scan(&userSuccess, &userErrMsg, &userData) + if err != nil { + return nil, fmt.Errorf("failed to get user data: %w", err) + } + if !userSuccess { + if userErrMsg != nil { + return nil, fmt.Errorf("%s", *userErrMsg) + } + return nil, fmt.Errorf("failed to get user data") + } + var userCtx UserContext + if err := json.Unmarshal(userData, &userCtx); err != nil { + return nil, fmt.Errorf("failed to parse user context: %w", err) + } + return &userCtx, nil +} diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/oauth_server_db.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/oauth_server_db.go index 4f9a095..d0e208d 100644 --- a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/oauth_server_db.go +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/oauth_server_db.go @@ -44,6 +44,10 @@ type OAuthTokenInfo struct { // OAuthRegisterClient persists an OAuth2 client registration. func (a *DatabaseAuthenticator) OAuthRegisterClient(ctx context.Context, client *OAuthServerClient) (*OAuthServerClient, error) { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthRegisterClient) { + return a.oauthRegisterClientDirect(ctx, client) + } + input, err := json.Marshal(client) if err != nil { return nil, fmt.Errorf("failed to marshal client: %w", err) @@ -76,6 +80,10 @@ func (a *DatabaseAuthenticator) OAuthRegisterClient(ctx context.Context, client // OAuthGetClient retrieves a registered client by ID. func (a *DatabaseAuthenticator) OAuthGetClient(ctx context.Context, clientID string) (*OAuthServerClient, error) { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthGetClient) { + return a.oauthGetClientDirect(ctx, clientID) + } + var success bool var errMsg *string var data []byte @@ -103,6 +111,10 @@ func (a *DatabaseAuthenticator) OAuthGetClient(ctx context.Context, clientID str // OAuthSaveCode persists an authorization code. func (a *DatabaseAuthenticator) OAuthSaveCode(ctx context.Context, code *OAuthCode) error { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthSaveCode) { + return a.oauthSaveCodeDirect(ctx, code) + } + input, err := json.Marshal(code) if err != nil { return fmt.Errorf("failed to marshal code: %w", err) @@ -129,6 +141,10 @@ func (a *DatabaseAuthenticator) OAuthSaveCode(ctx context.Context, code *OAuthCo // OAuthExchangeCode retrieves and deletes an authorization code (single use). func (a *DatabaseAuthenticator) OAuthExchangeCode(ctx context.Context, code string) (*OAuthCode, error) { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthExchangeCode) { + return a.oauthExchangeCodeDirect(ctx, code) + } + var success bool var errMsg *string var data []byte @@ -157,6 +173,10 @@ func (a *DatabaseAuthenticator) OAuthExchangeCode(ctx context.Context, code stri // OAuthIntrospectToken validates a token and returns its metadata (RFC 7662). func (a *DatabaseAuthenticator) OAuthIntrospectToken(ctx context.Context, token string) (*OAuthTokenInfo, error) { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthIntrospect) { + return a.oauthIntrospectTokenDirect(ctx, token) + } + var success bool var errMsg *string var data []byte @@ -184,6 +204,10 @@ func (a *DatabaseAuthenticator) OAuthIntrospectToken(ctx context.Context, token // OAuthRevokeToken revokes a token by deleting the session (RFC 7009). func (a *DatabaseAuthenticator) OAuthRevokeToken(ctx context.Context, token string) error { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthRevoke) { + return a.oauthRevokeTokenDirect(ctx, token) + } + var success bool var errMsg *string diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/oauth_server_db_direct.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/oauth_server_db_direct.go new file mode 100644 index 0000000..e0e162e --- /dev/null +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/oauth_server_db_direct.go @@ -0,0 +1,188 @@ +package security + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" +) + +// Direct-mode implementations mirroring the OAuth2 server stored procedures +// (resolvespec_oauth_register_client, etc.) in database_schema.sql, using +// plain SQL against TableNames.OAuthClients / TableNames.OAuthCodes. +// Array columns (redirect_uris, grant_types, allowed_scopes, scopes) are +// JSON-encoded TEXT instead of native Postgres arrays. + +func (a *DatabaseAuthenticator) oauthRegisterClientDirect(ctx context.Context, client *OAuthServerClient) (*OAuthServerClient, error) { + grantTypes := client.GrantTypes + if len(grantTypes) == 0 { + grantTypes = []string{"authorization_code"} + } + allowedScopes := client.AllowedScopes + if len(allowedScopes) == 0 { + allowedScopes = []string{"openid", "profile", "email"} + } + + redirectURIsJSON, err := json.Marshal(client.RedirectURIs) + if err != nil { + return nil, fmt.Errorf("failed to marshal redirect_uris: %w", err) + } + grantTypesJSON, err := json.Marshal(grantTypes) + if err != nil { + return nil, fmt.Errorf("failed to marshal grant_types: %w", err) + } + allowedScopesJSON, err := json.Marshal(allowedScopes) + if err != nil { + return nil, fmt.Errorf("failed to marshal allowed_scopes: %w", err) + } + + err = a.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf( + `INSERT INTO %s (client_id, redirect_uris, client_name, grant_types, allowed_scopes, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + a.tableNames.OAuthClients)) + _, err := db.ExecContext(ctx, query, client.ClientID, string(redirectURIsJSON), client.ClientName, string(grantTypesJSON), string(allowedScopesJSON), true, time.Now()) + return err + }) + if err != nil { + return nil, fmt.Errorf("failed to register client: %w", err) + } + + return &OAuthServerClient{ + ClientID: client.ClientID, + RedirectURIs: client.RedirectURIs, + ClientName: client.ClientName, + GrantTypes: grantTypes, + AllowedScopes: allowedScopes, + }, nil +} + +func (a *DatabaseAuthenticator) oauthGetClientDirect(ctx context.Context, clientID string) (*OAuthServerClient, error) { + var redirectURIsJSON, grantTypesJSON, allowedScopesJSON sql.NullString + var clientName sql.NullString + + err := a.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf( + `SELECT redirect_uris, client_name, grant_types, allowed_scopes FROM %s WHERE client_id = ? AND is_active = ?`, + a.tableNames.OAuthClients)) + return db.QueryRowContext(ctx, query, clientID, true).Scan(&redirectURIsJSON, &clientName, &grantTypesJSON, &allowedScopesJSON) + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("client not found") + } + return nil, fmt.Errorf("failed to get client: %w", err) + } + + result := &OAuthServerClient{ClientID: clientID, ClientName: clientName.String} + if redirectURIsJSON.Valid { + _ = json.Unmarshal([]byte(redirectURIsJSON.String), &result.RedirectURIs) + } + if grantTypesJSON.Valid { + _ = json.Unmarshal([]byte(grantTypesJSON.String), &result.GrantTypes) + } + if allowedScopesJSON.Valid { + _ = json.Unmarshal([]byte(allowedScopesJSON.String), &result.AllowedScopes) + } + return result, nil +} + +func (a *DatabaseAuthenticator) oauthSaveCodeDirect(ctx context.Context, code *OAuthCode) error { + scopesJSON, err := json.Marshal(code.Scopes) + if err != nil { + return fmt.Errorf("failed to marshal scopes: %w", err) + } + method := code.CodeChallengeMethod + if method == "" { + method = "S256" + } + + return a.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf( + `INSERT INTO %s (code, client_id, redirect_uri, client_state, code_challenge, code_challenge_method, session_token, refresh_token, scopes, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, a.tableNames.OAuthCodes)) + _, err := db.ExecContext(ctx, query, code.Code, code.ClientID, code.RedirectURI, code.ClientState, code.CodeChallenge, + method, code.SessionToken, code.RefreshToken, string(scopesJSON), code.ExpiresAt, time.Now()) + return err + }) +} + +func (a *DatabaseAuthenticator) oauthExchangeCodeDirect(ctx context.Context, code string) (*OAuthCode, error) { + var result OAuthCode + var clientState, refreshToken sql.NullString + var scopesJSON sql.NullString + + err := a.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf( + `SELECT client_id, redirect_uri, client_state, code_challenge, code_challenge_method, session_token, refresh_token, scopes + FROM %s WHERE code = ? AND expires_at > ?`, a.tableNames.OAuthCodes)) + err := db.QueryRowContext(ctx, query, code, time.Now()).Scan( + &result.ClientID, &result.RedirectURI, &clientState, &result.CodeChallenge, &result.CodeChallengeMethod, &result.SessionToken, &refreshToken, &scopesJSON) + if err != nil { + return err + } + delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE code = ?`, a.tableNames.OAuthCodes)) + _, err = db.ExecContext(ctx, delQuery, code) + return err + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("invalid or expired code") + } + return nil, fmt.Errorf("failed to exchange code: %w", err) + } + + result.Code = code + result.ClientState = clientState.String + result.RefreshToken = refreshToken.String + if scopesJSON.Valid { + _ = json.Unmarshal([]byte(scopesJSON.String), &result.Scopes) + } + return &result, nil +} + +func (a *DatabaseAuthenticator) oauthIntrospectTokenDirect(ctx context.Context, token string) (*OAuthTokenInfo, error) { + var info OAuthTokenInfo + var roles sql.NullString + var exp, iat sql.NullTime + + err := a.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf( + `SELECT u.id, u.username, u.email, u.user_level, u.roles, s.expires_at, s.created_at + FROM %s s JOIN %s u ON u.id = s.user_id + WHERE s.session_token = ? AND s.expires_at > ? AND u.is_active = ?`, + a.tableNames.UserSessions, a.tableNames.Users)) + var userID int + err := db.QueryRowContext(ctx, query, token, time.Now(), true).Scan(&userID, &info.Username, &info.Email, &info.UserLevel, &roles, &exp, &iat) + if err != nil { + return err + } + info.Sub = fmt.Sprintf("%d", userID) + return nil + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return &OAuthTokenInfo{Active: false}, nil + } + return nil, fmt.Errorf("failed to introspect token: %w", err) + } + + info.Active = true + info.Roles = parseRoles(roles.String) + if exp.Valid { + info.Exp = exp.Time.Unix() + } + if iat.Valid { + info.Iat = iat.Time.Unix() + } + return &info, nil +} + +func (a *DatabaseAuthenticator) oauthRevokeTokenDirect(ctx context.Context, token string) error { + return a.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE session_token = ?`, a.tableNames.UserSessions)) + _, err := db.ExecContext(ctx, query, token) + return err + }) +} diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/passkey_provider.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/passkey_provider.go index fb153c1..003e968 100644 --- a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/passkey_provider.go +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/passkey_provider.go @@ -14,14 +14,17 @@ import ( // DatabasePasskeyProvider implements PasskeyProvider using database storage // Procedure names are configurable via SQLNames (see DefaultSQLNames for defaults) type DatabasePasskeyProvider struct { - db *sql.DB - dbMu sync.RWMutex - dbFactory func() (*sql.DB, error) - rpID string // Relying Party ID (domain) - rpName string // Relying Party display name - rpOrigin string // Expected origin for WebAuthn - timeout int64 // Timeout in milliseconds (default: 60000) - sqlNames *SQLNames + db *sql.DB + dbMu sync.RWMutex + dbFactory func() (*sql.DB, error) + rpID string // Relying Party ID (domain) + rpName string // Relying Party display name + rpOrigin string // Expected origin for WebAuthn + timeout int64 // Timeout in milliseconds (default: 60000) + sqlNames *SQLNames + tableNames *TableNames + queryMode QueryMode + capability *dbCapability } // DatabasePasskeyProviderOptions configures the passkey provider @@ -36,6 +39,10 @@ type DatabasePasskeyProviderOptions struct { Timeout int64 // SQLNames provides custom SQL procedure/function names. If nil, uses DefaultSQLNames(). SQLNames *SQLNames + // TableNames provides custom table names for Direct mode. If nil, uses DefaultTableNames(). + TableNames *TableNames + // QueryMode selects stored-procedure vs Direct-mode SQL. Default (zero value) is ModeAuto. + QueryMode QueryMode // 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) @@ -48,15 +55,19 @@ func NewDatabasePasskeyProvider(db *sql.DB, opts DatabasePasskeyProviderOptions) } sqlNames := MergeSQLNames(DefaultSQLNames(), opts.SQLNames) + tableNames := resolveTableNames(opts.TableNames) return &DatabasePasskeyProvider{ - db: db, - dbFactory: opts.DBFactory, - rpID: opts.RPID, - rpName: opts.RPName, - rpOrigin: opts.RPOrigin, - timeout: opts.Timeout, - sqlNames: sqlNames, + db: db, + dbFactory: opts.DBFactory, + rpID: opts.RPID, + rpName: opts.RPName, + rpOrigin: opts.RPOrigin, + timeout: opts.Timeout, + sqlNames: sqlNames, + tableNames: tableNames, + queryMode: opts.QueryMode, + capability: newDBCapability(), } } @@ -77,9 +88,26 @@ func (p *DatabasePasskeyProvider) reconnectDB() error { p.dbMu.Lock() p.db = newDB p.dbMu.Unlock() + if p.capability != nil { + p.capability.reset() + } return nil } +func (p *DatabasePasskeyProvider) runDBOpWithReconnect(run func(*sql.DB) error) error { + db := p.getDB() + if db == nil { + return fmt.Errorf("database connection is nil") + } + err := run(db) + if isDBClosed(err) { + if reconnErr := p.reconnectDB(); reconnErr == nil { + err = run(p.getDB()) + } + } + return err +} + // BeginRegistration creates registration options for a new passkey func (p *DatabasePasskeyProvider) BeginRegistration(ctx context.Context, userID int, username, displayName string) (*PasskeyRegistrationOptions, error) { // Generate challenge @@ -145,10 +173,40 @@ func (p *DatabasePasskeyProvider) CompleteRegistration(ctx context.Context, user // For now, this is a placeholder that stores the credential data // In production, you MUST use a proper WebAuthn library + credIDB64 := base64.StdEncoding.EncodeToString(response.RawID) + pubKeyB64 := base64.StdEncoding.EncodeToString(response.Response.AttestationObject) + + if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyStoreCredential) { + credentialID, err := p.storeCredentialDirect(ctx, storeCredentialParams{ + UserID: userID, + CredentialID: credIDB64, + PublicKey: pubKeyB64, + AttestationType: "none", + SignCount: 0, + Transports: response.Transports, + BackupEligible: false, + BackupState: false, + Name: "Passkey", + }) + if err != nil { + return nil, err + } + return &PasskeyCredential{ + ID: fmt.Sprintf("%d", credentialID), + UserID: userID, + CredentialID: response.RawID, + PublicKey: response.Response.AttestationObject, + AttestationType: "none", + Transports: response.Transports, + CreatedAt: time.Now(), + LastUsedAt: time.Now(), + }, nil + } + credData := map[string]any{ "user_id": userID, - "credential_id": base64.StdEncoding.EncodeToString(response.RawID), - "public_key": base64.StdEncoding.EncodeToString(response.Response.AttestationObject), + "credential_id": credIDB64, + "public_key": pubKeyB64, "attestation_type": "none", "sign_count": 0, "transports": response.Transports, @@ -202,31 +260,36 @@ func (p *DatabasePasskeyProvider) BeginAuthentication(ctx context.Context, usern // If username is provided, get user's credentials var allowCredentials []PasskeyCredentialDescriptor if username != "" { - var success bool - var errorMsg sql.NullString - var userID sql.NullInt64 - var credentialsJSON sql.NullString + var creds []passkeyCredential - query := fmt.Sprintf(`SELECT p_success, p_error, p_user_id, p_credentials::text FROM %s($1)`, p.sqlNames.PasskeyGetCredsByUsername) - err := p.getDB().QueryRowContext(ctx, query, username).Scan(&success, &errorMsg, &userID, &credentialsJSON) - if err != nil { - return nil, fmt.Errorf("failed to get credentials: %w", err) - } - - if !success { - if errorMsg.Valid { - return nil, fmt.Errorf("%s", errorMsg.String) + if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyGetCredsByUsername) { + _, directCreds, err := p.getCredsByUsernameDirect(ctx, username) + if err != nil { + return nil, err } - return nil, fmt.Errorf("failed to get credentials") - } + creds = directCreds + } else { + var success bool + var errorMsg sql.NullString + var userID sql.NullInt64 + var credentialsJSON sql.NullString - // Parse credentials - var creds []struct { - ID string `json:"credential_id"` - Transports []string `json:"transports"` - } - if err := json.Unmarshal([]byte(credentialsJSON.String), &creds); err != nil { - return nil, fmt.Errorf("failed to parse credentials: %w", err) + query := fmt.Sprintf(`SELECT p_success, p_error, p_user_id, p_credentials::text FROM %s($1)`, p.sqlNames.PasskeyGetCredsByUsername) + err := p.getDB().QueryRowContext(ctx, query, username).Scan(&success, &errorMsg, &userID, &credentialsJSON) + if err != nil { + return nil, fmt.Errorf("failed to get credentials: %w", err) + } + + if !success { + if errorMsg.Valid { + return nil, fmt.Errorf("%s", errorMsg.String) + } + return nil, fmt.Errorf("failed to get credentials") + } + + if err := json.Unmarshal([]byte(credentialsJSON.String), &creds); err != nil { + return nil, fmt.Errorf("failed to parse credentials: %w", err) + } } allowCredentials = make([]PasskeyCredentialDescriptor, 0, len(creds)) @@ -262,6 +325,24 @@ func (p *DatabasePasskeyProvider) CompleteAuthentication(ctx context.Context, re // 3. Verify signature using stored public key // 4. Update sign counter and check for cloning + credIDB64 := base64.StdEncoding.EncodeToString(response.RawID) + + if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyGetCredential) { + userID, signCount, err := p.getCredentialDirect(ctx, credIDB64) + if err != nil { + return 0, err + } + newCounter := signCount + 1 + cloneWarning, err := p.updateCounterDirect(ctx, credIDB64, newCounter) + if err != nil { + return 0, fmt.Errorf("failed to update counter: %w", err) + } + if cloneWarning { + return 0, fmt.Errorf("credential cloning detected") + } + return userID, nil + } + // Get credential from database var success bool var errorMsg sql.NullString @@ -321,6 +402,10 @@ func (p *DatabasePasskeyProvider) CompleteAuthentication(ctx context.Context, re // GetCredentials returns all passkey credentials for a user func (p *DatabasePasskeyProvider) GetCredentials(ctx context.Context, userID int) ([]PasskeyCredential, error) { + if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyGetUserCredentials) { + return p.getUserCredentialsDirect(ctx, userID) + } + var success bool var errorMsg sql.NullString var credentialsJSON sql.NullString @@ -401,6 +486,10 @@ func (p *DatabasePasskeyProvider) DeleteCredential(ctx context.Context, userID i return fmt.Errorf("invalid credential ID: %w", err) } + if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyDeleteCredential) { + return p.deleteCredentialDirect(ctx, userID, credentialID) + } + var success bool var errorMsg sql.NullString @@ -427,6 +516,10 @@ func (p *DatabasePasskeyProvider) UpdateCredentialName(ctx context.Context, user return fmt.Errorf("invalid credential ID: %w", err) } + if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyUpdateName) { + return p.updateNameDirect(ctx, userID, credentialID, name) + } + var success bool var errorMsg sql.NullString diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/passkey_provider_direct.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/passkey_provider_direct.go new file mode 100644 index 0000000..229121f --- /dev/null +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/passkey_provider_direct.go @@ -0,0 +1,256 @@ +package security + +import ( + "context" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "time" +) + +// Direct-mode implementations mirroring the resolvespec_passkey_* stored +// procedures in database_schema.sql using plain SQL against TableNames. +// credential_id/public_key/aaguid are stored as base64 TEXT (not native +// bytea) and transports as JSON-encoded TEXT, so the same schema works on +// SQLite, MySQL, and Postgres. + +type storeCredentialParams struct { + UserID int + CredentialID string // base64 + PublicKey string // base64 + AttestationType string + SignCount int + Transports []string + BackupEligible bool + BackupState bool + Name string +} + +func (p *DatabasePasskeyProvider) storeCredentialDirect(ctx context.Context, params storeCredentialParams) (int64, error) { + transportsJSON, err := json.Marshal(params.Transports) + if err != nil { + return 0, fmt.Errorf("failed to marshal transports: %w", err) + } + + var credentialID int64 + err = p.runDBOpWithReconnect(func(db *sql.DB) error { + var exists int + checkQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT 1 FROM %s WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials)) + if err := db.QueryRowContext(ctx, checkQuery, params.CredentialID).Scan(&exists); err == nil { + return fmt.Errorf("credential already exists") + } else if !errors.Is(err, sql.ErrNoRows) { + return err + } + + var userExists int + userCheckQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT 1 FROM %s WHERE id = ?`, p.tableNames.Users)) + if err := db.QueryRowContext(ctx, userCheckQuery, params.UserID).Scan(&userExists); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("user not found") + } + return err + } + + now := time.Now() + insertQuery := rewritePlaceholders(db, fmt.Sprintf( + `INSERT INTO %s (user_id, credential_id, public_key, attestation_type, aaguid, sign_count, transports, backup_eligible, backup_state, name, created_at, last_used_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, p.tableNames.UserPasskeyCredentials)) + res, err := db.ExecContext(ctx, insertQuery, params.UserID, params.CredentialID, params.PublicKey, params.AttestationType, + "", params.SignCount, string(transportsJSON), params.BackupEligible, params.BackupState, params.Name, now, now) + if err != nil { + return err + } + credentialID, err = res.LastInsertId() + return err + }) + if err != nil { + return 0, err + } + return credentialID, nil +} + +func (p *DatabasePasskeyProvider) getCredentialDirect(ctx context.Context, credentialIDB64 string) (userID int, signCount uint32, err error) { + err = p.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf(`SELECT user_id, sign_count FROM %s WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials)) + return db.QueryRowContext(ctx, query, credentialIDB64).Scan(&userID, &signCount) + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, 0, fmt.Errorf("credential not found") + } + return 0, 0, fmt.Errorf("failed to get credential: %w", err) + } + return userID, signCount, nil +} + +func (p *DatabasePasskeyProvider) updateCounterDirect(ctx context.Context, credentialIDB64 string, newCounter uint32) (cloneWarning bool, err error) { + err = p.runDBOpWithReconnect(func(db *sql.DB) error { + var oldCounter int + query := rewritePlaceholders(db, fmt.Sprintf(`SELECT sign_count FROM %s WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials)) + if err := db.QueryRowContext(ctx, query, credentialIDB64).Scan(&oldCounter); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("credential not found") + } + return err + } + + if int(newCounter) <= oldCounter { + cloneWarning = true + updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET clone_warning = ? WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials)) + _, err := db.ExecContext(ctx, updQuery, true, credentialIDB64) + return err + } + + updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET sign_count = ?, last_used_at = ? WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials)) + _, err := db.ExecContext(ctx, updQuery, newCounter, time.Now(), credentialIDB64) + return err + }) + return cloneWarning, err +} + +func (p *DatabasePasskeyProvider) getUserCredentialsDirect(ctx context.Context, userID int) ([]PasskeyCredential, error) { + var credentials []PasskeyCredential + err := p.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf( + `SELECT id, user_id, credential_id, public_key, attestation_type, aaguid, sign_count, clone_warning, transports, backup_eligible, backup_state, name, created_at, last_used_at + FROM %s WHERE user_id = ? ORDER BY created_at DESC`, p.tableNames.UserPasskeyCredentials)) + rows, err := db.QueryContext(ctx, query, userID) + if err != nil { + return err + } + defer rows.Close() + + credentials = make([]PasskeyCredential, 0) + for rows.Next() { + var id, uid int + var credIDB64, pubKeyB64, attestationType, aaguidB64, name string + var signCount uint32 + var cloneWarning, backupEligible, backupState bool + var transportsJSON sql.NullString + var createdAt, lastUsedAt time.Time + + if err := rows.Scan(&id, &uid, &credIDB64, &pubKeyB64, &attestationType, &aaguidB64, &signCount, + &cloneWarning, &transportsJSON, &backupEligible, &backupState, &name, &createdAt, &lastUsedAt); err != nil { + return err + } + + credID, err := base64.StdEncoding.DecodeString(credIDB64) + if err != nil { + continue + } + pubKey, err := base64.StdEncoding.DecodeString(pubKeyB64) + if err != nil { + continue + } + aaguid, _ := base64.StdEncoding.DecodeString(aaguidB64) + + var transports []string + if transportsJSON.Valid && transportsJSON.String != "" { + _ = json.Unmarshal([]byte(transportsJSON.String), &transports) + } + + credentials = append(credentials, PasskeyCredential{ + ID: fmt.Sprintf("%d", id), + UserID: uid, + CredentialID: credID, + PublicKey: pubKey, + AttestationType: attestationType, + AAGUID: aaguid, + SignCount: signCount, + CloneWarning: cloneWarning, + Transports: transports, + BackupEligible: backupEligible, + BackupState: backupState, + Name: name, + CreatedAt: createdAt, + LastUsedAt: lastUsedAt, + }) + } + return rows.Err() + }) + if err != nil { + return nil, fmt.Errorf("failed to get credentials: %w", err) + } + return credentials, nil +} + +func (p *DatabasePasskeyProvider) deleteCredentialDirect(ctx context.Context, userID int, credentialIDB64 string) error { + return p.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ? AND credential_id = ?`, p.tableNames.UserPasskeyCredentials)) + res, err := db.ExecContext(ctx, query, userID, credentialIDB64) + if err != nil { + return err + } + rows, err := res.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("credential not found") + } + return nil + }) +} + +func (p *DatabasePasskeyProvider) updateNameDirect(ctx context.Context, userID int, credentialIDB64, name string) error { + return p.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET name = ? WHERE user_id = ? AND credential_id = ?`, p.tableNames.UserPasskeyCredentials)) + res, err := db.ExecContext(ctx, query, name, userID, credentialIDB64) + if err != nil { + return err + } + rows, err := res.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("credential not found") + } + return nil + }) +} + +type passkeyCredential struct { + ID string `json:"credential_id"` + Transports []string `json:"transports"` +} + +func (p *DatabasePasskeyProvider) getCredsByUsernameDirect(ctx context.Context, username string) (userID int, creds []passkeyCredential, err error) { + err = p.runDBOpWithReconnect(func(db *sql.DB) error { + userQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT id FROM %s WHERE username = ? AND is_active = ?`, p.tableNames.Users)) + if err := db.QueryRowContext(ctx, userQuery, username, true).Scan(&userID); err != nil { + return err + } + + query := rewritePlaceholders(db, fmt.Sprintf(`SELECT credential_id, transports FROM %s WHERE user_id = ?`, p.tableNames.UserPasskeyCredentials)) + rows, err := db.QueryContext(ctx, query, userID) + if err != nil { + return err + } + defer rows.Close() + + creds = make([]passkeyCredential, 0) + for rows.Next() { + var credID string + var transportsJSON sql.NullString + if err := rows.Scan(&credID, &transportsJSON); err != nil { + return err + } + var transports []string + if transportsJSON.Valid && transportsJSON.String != "" { + _ = json.Unmarshal([]byte(transportsJSON.String), &transports) + } + creds = append(creds, passkeyCredential{ID: credID, Transports: transports}) + } + return rows.Err() + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, nil, fmt.Errorf("user not found") + } + return 0, nil, fmt.Errorf("failed to get credentials: %w", err) + } + return userID, creds, nil +} diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/provider.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/provider.go index 7dcf609..8f638f5 100644 --- a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/provider.go +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/provider.go @@ -34,7 +34,10 @@ type RowSecurity struct { Tablename string `json:"tablename"` Template string `json:"template"` HasBlock bool `json:"has_block"` - UserID int `json:"user_id"` + // UserID is the opaque user reference the security rules were loaded for. + // It may be an int, a string/UUID, or a *UserContext, depending on what the + // RowSecurityProvider/SecurityContext.GetUserRef implementation returns. + UserID any `json:"user_id"` } func (m *RowSecurity) GetTemplate(pPrimaryKeyName string, pModelType reflect.Type) string { @@ -42,7 +45,7 @@ func (m *RowSecurity) GetTemplate(pPrimaryKeyName string, pModelType reflect.Typ str = strings.ReplaceAll(str, "{PrimaryKeyName}", pPrimaryKeyName) str = strings.ReplaceAll(str, "{TableName}", m.Tablename) str = strings.ReplaceAll(str, "{SchemaName}", m.Schema) - str = strings.ReplaceAll(str, "{UserID}", fmt.Sprintf("%d", m.UserID)) + str = strings.ReplaceAll(str, "{UserID}", fmt.Sprintf("%v", m.UserID)) return str } @@ -413,7 +416,7 @@ func (m *SecurityList) ClearSecurity(pUserID int, pSchema, pTablename string) er return nil } -func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserID int, pSchema, pTablename string, pOverwrite bool) (RowSecurity, error) { +func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserRef any, pSchema, pTablename string, pOverwrite bool) (RowSecurity, error) { if m.provider == nil { return RowSecurity{}, fmt.Errorf("security provider not set") } @@ -424,10 +427,10 @@ func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserID int, pSchema if m.RowSecurity == nil { m.RowSecurity = make(map[string]RowSecurity, 0) } - secKey := fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID) + secKey := fmt.Sprintf("%s.%s@%v", pSchema, pTablename, pUserRef) // Call the provider to load security rules - record, err := m.provider.GetRowSecurity(ctx, pUserID, pSchema, pTablename) + record, err := m.provider.GetRowSecurity(ctx, pUserRef, pSchema, pTablename) if err != nil { return RowSecurity{}, fmt.Errorf("GetRowSecurity failed: %v", err) } @@ -436,7 +439,7 @@ func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserID int, pSchema return record, nil } -func (m *SecurityList) GetRowSecurityTemplate(pUserID int, pSchema, pTablename string) (RowSecurity, error) { +func (m *SecurityList) GetRowSecurityTemplate(pUserRef any, pSchema, pTablename string) (RowSecurity, error) { defer logger.CatchPanic("GetRowSecurityTemplate")() if m.RowSecurity == nil { @@ -446,7 +449,7 @@ func (m *SecurityList) GetRowSecurityTemplate(pUserID int, pSchema, pTablename s m.RowSecurityMutex.RLock() defer m.RowSecurityMutex.RUnlock() - rowSec, ok := m.RowSecurity[fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID)] + rowSec, ok := m.RowSecurity[fmt.Sprintf("%s.%s@%v", pSchema, pTablename, pUserRef)] if !ok { return RowSecurity{}, fmt.Errorf("no row security data") } diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/providers.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/providers.go index 385d16d..ca6d20f 100644 --- a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/providers.go +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/providers.go @@ -71,12 +71,15 @@ func (a *HeaderAuthenticator) Authenticate(r *http.Request) (*UserContext, error // Also supports multiple OAuth2 providers configured with WithOAuth2() // Also supports passkey authentication configured with WithPasskey() type DatabaseAuthenticator struct { - db *sql.DB - dbMu sync.RWMutex - dbFactory func() (*sql.DB, error) - cache *cache.Cache - cacheTTL time.Duration - sqlNames *SQLNames + db *sql.DB + dbMu sync.RWMutex + dbFactory func() (*sql.DB, error) + cache *cache.Cache + cacheTTL time.Duration + sqlNames *SQLNames + tableNames *TableNames + queryMode QueryMode + capability *dbCapability // Cookie session support (optional, gated by enableCookieSession) enableCookieSession bool @@ -105,6 +108,10 @@ type DatabaseAuthenticatorOptions struct { // SQLNames provides custom SQL procedure/function names. If nil, uses DefaultSQLNames(). // Partial overrides are supported: only set the fields you want to change. SQLNames *SQLNames + // TableNames provides custom table names for Direct mode. If nil, uses DefaultTableNames(). + TableNames *TableNames + // QueryMode selects stored-procedure vs Direct-mode SQL. Default (zero value) is ModeAuto. + QueryMode QueryMode // 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) @@ -139,6 +146,7 @@ func NewDatabaseAuthenticatorWithOptions(db *sql.DB, opts DatabaseAuthenticatorO } sqlNames := MergeSQLNames(DefaultSQLNames(), opts.SQLNames) + tableNames := resolveTableNames(opts.TableNames) return &DatabaseAuthenticator{ db: db, @@ -146,6 +154,9 @@ func NewDatabaseAuthenticatorWithOptions(db *sql.DB, opts DatabaseAuthenticatorO cache: cacheInstance, cacheTTL: opts.CacheTTL, sqlNames: sqlNames, + tableNames: tableNames, + queryMode: opts.QueryMode, + capability: newDBCapability(), passkeyProvider: opts.PasskeyProvider, enableCookieSession: opts.EnableCookieSession, cookieOptions: opts.CookieOptions, @@ -170,6 +181,9 @@ func (a *DatabaseAuthenticator) reconnectDB() error { a.dbMu.Lock() a.db = newDB a.dbMu.Unlock() + if a.capability != nil { + a.capability.reset() + } return nil } @@ -194,6 +208,9 @@ func (a *DatabaseAuthenticator) SetAuthenticateCallback(fn func(r *http.Request) } func (a *DatabaseAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.Login) { + return a.loginDirect(ctx, req) + } // Convert LoginRequest to JSON reqJSON, err := json.Marshal(req) if err != nil { @@ -230,6 +247,9 @@ func (a *DatabaseAuthenticator) Login(ctx context.Context, req LoginRequest) (*L // Register implements Registrable interface func (a *DatabaseAuthenticator) Register(ctx context.Context, req RegisterRequest) (*LoginResponse, error) { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.Register) { + return a.registerDirect(ctx, req) + } // Convert RegisterRequest to JSON reqJSON, err := json.Marshal(req) if err != nil { @@ -265,6 +285,9 @@ func (a *DatabaseAuthenticator) Register(ctx context.Context, req RegisterReques } func (a *DatabaseAuthenticator) Logout(ctx context.Context, req LogoutRequest) error { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.Logout) { + return a.logoutDirect(ctx, req) + } // Convert LogoutRequest to JSON reqJSON, err := json.Marshal(req) if err != nil { @@ -378,6 +401,10 @@ func (a *DatabaseAuthenticator) Authenticate(r *http.Request) (*UserContext, err var userCtx UserContext err := a.cache.GetOrSet(r.Context(), cacheKey, &userCtx, a.cacheTTL, func() (any, error) { // This function is called only if cache miss + if !a.capability.ShouldUseProcedure(r.Context(), a.queryMode, a.getDB(), a.sqlNames.Session) { + return a.sessionDirect(r.Context(), token) + } + var success bool var errorMsg sql.NullString var userJSON sql.NullString @@ -453,6 +480,11 @@ func (a *DatabaseAuthenticator) ClearUserCache(userID int) error { // updateSessionActivity updates the last activity timestamp for the session func (a *DatabaseAuthenticator) updateSessionActivity(ctx context.Context, sessionToken string, userCtx *UserContext) { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.SessionUpdate) { + _ = a.updateSessionActivityDirect(ctx, sessionToken) + return + } + // Convert UserContext to JSON userJSON, err := json.Marshal(userCtx) if err != nil { @@ -471,6 +503,9 @@ func (a *DatabaseAuthenticator) updateSessionActivity(ctx context.Context, sessi // RefreshToken implements Refreshable interface func (a *DatabaseAuthenticator) RefreshToken(ctx context.Context, refreshToken string) (*LoginResponse, error) { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.RefreshToken) { + return a.refreshTokenDirect(ctx, refreshToken) + } // First, we need to get the current user context for the refresh token var success bool var errorMsg sql.NullString @@ -528,18 +563,23 @@ func (a *DatabaseAuthenticator) RefreshToken(ctx context.Context, refreshToken s // Procedure names are configurable via SQLNames (see DefaultSQLNames for defaults) // NOTE: JWT signing/verification requires github.com/golang-jwt/jwt/v5 to be installed and imported type JWTAuthenticator struct { - secretKey []byte - db *sql.DB - dbMu sync.RWMutex - dbFactory func() (*sql.DB, error) - sqlNames *SQLNames + secretKey []byte + db *sql.DB + dbMu sync.RWMutex + dbFactory func() (*sql.DB, error) + sqlNames *SQLNames + tableNames *TableNames + queryMode QueryMode + capability *dbCapability } func NewJWTAuthenticator(secretKey string, db *sql.DB, names ...*SQLNames) *JWTAuthenticator { return &JWTAuthenticator{ - secretKey: []byte(secretKey), - db: db, - sqlNames: resolveSQLNames(names...), + secretKey: []byte(secretKey), + db: db, + sqlNames: resolveSQLNames(names...), + tableNames: DefaultTableNames(), + capability: newDBCapability(), } } @@ -549,6 +589,18 @@ func (a *JWTAuthenticator) WithDBFactory(factory func() (*sql.DB, error)) *JWTAu return a } +// WithTableNames configures Direct-mode table names. If names is nil, defaults are used. +func (a *JWTAuthenticator) WithTableNames(names *TableNames) *JWTAuthenticator { + a.tableNames = resolveTableNames(names) + return a +} + +// WithQueryMode selects stored-procedure vs Direct-mode SQL (default ModeAuto). +func (a *JWTAuthenticator) WithQueryMode(mode QueryMode) *JWTAuthenticator { + a.queryMode = mode + return a +} + func (a *JWTAuthenticator) getDB() *sql.DB { a.dbMu.RLock() defer a.dbMu.RUnlock() @@ -566,10 +618,17 @@ func (a *JWTAuthenticator) reconnectDB() error { a.dbMu.Lock() a.db = newDB a.dbMu.Unlock() + if a.capability != nil { + a.capability.reset() + } return nil } func (a *JWTAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.JWTLogin) { + return a.jwtLoginDirect(ctx, req) + } + var success bool var errorMsg sql.NullString var userJSON []byte @@ -632,6 +691,10 @@ func (a *JWTAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginR } func (a *JWTAuthenticator) Logout(ctx context.Context, req LogoutRequest) error { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.JWTLogout) { + return a.jwtLogoutDirect(ctx, req) + } + var success bool var errorMsg sql.NullString @@ -681,14 +744,23 @@ func (a *JWTAuthenticator) Authenticate(r *http.Request) (*UserContext, error) { // All database operations go through stored procedures // Procedure names are configurable via SQLNames (see DefaultSQLNames for defaults) type DatabaseColumnSecurityProvider struct { - db *sql.DB - dbMu sync.RWMutex - dbFactory func() (*sql.DB, error) - sqlNames *SQLNames + db *sql.DB + dbMu sync.RWMutex + dbFactory func() (*sql.DB, error) + sqlNames *SQLNames + queryMode QueryMode + capability *dbCapability } func NewDatabaseColumnSecurityProvider(db *sql.DB, names ...*SQLNames) *DatabaseColumnSecurityProvider { - return &DatabaseColumnSecurityProvider{db: db, sqlNames: resolveSQLNames(names...)} + return &DatabaseColumnSecurityProvider{db: db, sqlNames: resolveSQLNames(names...), capability: newDBCapability()} +} + +// WithQueryMode selects stored-procedure vs Direct-mode SQL (default ModeAuto). +// Direct mode is unsupported for column security (see ErrDirectModeUnsupported). +func (p *DatabaseColumnSecurityProvider) WithQueryMode(mode QueryMode) *DatabaseColumnSecurityProvider { + p.queryMode = mode + return p } func (p *DatabaseColumnSecurityProvider) WithDBFactory(factory func() (*sql.DB, error)) *DatabaseColumnSecurityProvider { @@ -713,10 +785,17 @@ func (p *DatabaseColumnSecurityProvider) reconnectDB() error { p.dbMu.Lock() p.db = newDB p.dbMu.Unlock() + if p.capability != nil { + p.capability.reset() + } return nil } func (p *DatabaseColumnSecurityProvider) GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]ColumnSecurity, error) { + if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.ColumnSecurity) { + return nil, ErrDirectModeUnsupported + } + var rules []ColumnSecurity var success bool @@ -781,14 +860,23 @@ func (p *DatabaseColumnSecurityProvider) GetColumnSecurity(ctx context.Context, // All database operations go through stored procedures // Procedure names are configurable via SQLNames (see DefaultSQLNames for defaults) type DatabaseRowSecurityProvider struct { - db *sql.DB - dbMu sync.RWMutex - dbFactory func() (*sql.DB, error) - sqlNames *SQLNames + db *sql.DB + dbMu sync.RWMutex + dbFactory func() (*sql.DB, error) + sqlNames *SQLNames + queryMode QueryMode + capability *dbCapability } func NewDatabaseRowSecurityProvider(db *sql.DB, names ...*SQLNames) *DatabaseRowSecurityProvider { - return &DatabaseRowSecurityProvider{db: db, sqlNames: resolveSQLNames(names...)} + return &DatabaseRowSecurityProvider{db: db, sqlNames: resolveSQLNames(names...), capability: newDBCapability()} +} + +// WithQueryMode selects stored-procedure vs Direct-mode SQL (default ModeAuto). +// Direct mode is unsupported for row security (see ErrDirectModeUnsupported). +func (p *DatabaseRowSecurityProvider) WithQueryMode(mode QueryMode) *DatabaseRowSecurityProvider { + p.queryMode = mode + return p } func (p *DatabaseRowSecurityProvider) WithDBFactory(factory func() (*sql.DB, error)) *DatabaseRowSecurityProvider { @@ -813,16 +901,23 @@ func (p *DatabaseRowSecurityProvider) reconnectDB() error { p.dbMu.Lock() p.db = newDB p.dbMu.Unlock() + if p.capability != nil { + p.capability.reset() + } return nil } -func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) { +func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) { + if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.RowSecurity) { + return RowSecurity{}, ErrDirectModeUnsupported + } + var template string var hasBlock bool runQuery := func() error { query := fmt.Sprintf(`SELECT p_template, p_block FROM %s($1, $2, $3)`, p.sqlNames.RowSecurity) - return p.getDB().QueryRowContext(ctx, query, schema, table, userID).Scan(&template, &hasBlock) + return p.getDB().QueryRowContext(ctx, query, schema, table, userRef).Scan(&template, &hasBlock) } err := runQuery() if isDBClosed(err) { @@ -837,7 +932,7 @@ func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userID return RowSecurity{ Schema: schema, Tablename: table, - UserID: userID, + UserID: userRef, Template: template, HasBlock: hasBlock, }, nil @@ -874,14 +969,14 @@ func NewConfigRowSecurityProvider(templates map[string]string, blocked map[strin } } -func (p *ConfigRowSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) { +func (p *ConfigRowSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) { key := fmt.Sprintf("%s.%s", schema, table) if p.blocked[key] { return RowSecurity{ Schema: schema, Tablename: table, - UserID: userID, + UserID: userRef, HasBlock: true, }, nil } @@ -890,7 +985,7 @@ func (p *ConfigRowSecurityProvider) GetRowSecurity(ctx context.Context, userID i return RowSecurity{ Schema: schema, Tablename: table, - UserID: userID, + UserID: userRef, Template: template, HasBlock: false, }, nil @@ -950,6 +1045,9 @@ func generateRandomString(length int) string { // RequestPasswordReset implements PasswordResettable. It calls the stored procedure // resolvespec_password_reset_request and returns the reset token and expiry. func (a *DatabaseAuthenticator) RequestPasswordReset(ctx context.Context, req PasswordResetRequest) (*PasswordResetResponse, error) { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.PasswordResetRequest) { + return a.requestPasswordResetDirect(ctx, req) + } reqJSON, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("failed to marshal password reset request: %w", err) @@ -987,6 +1085,9 @@ func (a *DatabaseAuthenticator) RequestPasswordReset(ctx context.Context, req Pa // CompletePasswordReset implements PasswordResettable. It validates the token and // updates the user's password via resolvespec_password_reset. func (a *DatabaseAuthenticator) CompletePasswordReset(ctx context.Context, req PasswordResetCompleteRequest) error { + if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.PasswordResetComplete) { + return a.completePasswordResetDirect(ctx, req) + } reqJSON, err := json.Marshal(req) if err != nil { return fmt.Errorf("failed to marshal password reset complete request: %w", err) diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/providers_direct.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/providers_direct.go new file mode 100644 index 0000000..a148a1e --- /dev/null +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/providers_direct.go @@ -0,0 +1,473 @@ +package security + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" +) + +// Direct-mode implementations for DatabaseAuthenticator and JWTAuthenticator. +// These mirror the plpgsql bodies in database_schema.sql using plain +// parameterized SQL against the configured TableNames, so they work on +// SQLite, MySQL, or Postgres without the resolvespec_* functions installed. +// +// Password verification is intentionally not implemented here: the stored +// procedures never verify the password hash either (see the TODOs in +// database_schema.sql), so Direct mode matches that behavior exactly rather +// than introducing a mismatch between modes. + +var ( + errUsernameExists = errors.New("username already exists") + errEmailExists = errors.New("email already exists") +) + +func (a *DatabaseAuthenticator) loginDirect(ctx context.Context, req LoginRequest) (*LoginResponse, error) { + var userID int + var email, roles, programUserTable sql.NullString + var userLevel, programUserID sql.NullInt64 + + err := a.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf( + `SELECT id, email, user_level, roles, program_user_id, program_user_table FROM %s WHERE username = ? AND is_active = ?`, + a.tableNames.Users)) + return db.QueryRowContext(ctx, query, req.Username, true).Scan(&userID, &email, &userLevel, &roles, &programUserID, &programUserTable) + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("invalid credentials") + } + return nil, fmt.Errorf("login query failed: %w", err) + } + + sessionToken, err := generateSessionToken() + if err != nil { + return nil, fmt.Errorf("failed to generate session token: %w", err) + } + now := time.Now() + expiresAt := now.Add(24 * time.Hour) + ipAddress, userAgent := claimStrings(req.Claims) + + err = a.runDBOpWithReconnect(func(db *sql.DB) error { + insertQuery := rewritePlaceholders(db, fmt.Sprintf( + `INSERT INTO %s (session_token, user_id, expires_at, ip_address, user_agent, last_activity_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + a.tableNames.UserSessions)) + if _, err := db.ExecContext(ctx, insertQuery, sessionToken, userID, expiresAt, ipAddress, userAgent, now, now); err != nil { + return err + } + updateQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET last_login_at = ? WHERE id = ?`, a.tableNames.Users)) + _, err := db.ExecContext(ctx, updateQuery, now, userID) + return err + }) + if err != nil { + return nil, fmt.Errorf("login query failed: %w", err) + } + + userCtx := &UserContext{ + UserID: userID, + UserName: req.Username, + Email: email.String, + UserLevel: int(userLevel.Int64), + Roles: parseRoles(roles.String), + SessionID: sessionToken, + ProgramUserID: int(programUserID.Int64), + ProgramUserTable: programUserTable.String, + } + + return &LoginResponse{ + Token: sessionToken, + User: userCtx, + ExpiresIn: 86400, + }, nil +} + +func (a *DatabaseAuthenticator) registerDirect(ctx context.Context, req RegisterRequest) (*LoginResponse, error) { + if req.Username == "" { + return nil, fmt.Errorf("username is required") + } + if req.Email == "" { + return nil, fmt.Errorf("email is required") + } + if req.Password == "" { + return nil, fmt.Errorf("password is required") + } + + rolesStr := strings.Join(req.Roles, ",") + now := time.Now() + ipAddress, userAgent := claimStrings(req.Claims) + + var userID int64 + err := a.runDBOpWithReconnect(func(db *sql.DB) error { + var count int + checkQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE username = ?`, a.tableNames.Users)) + if err := db.QueryRowContext(ctx, checkQuery, req.Username).Scan(&count); err != nil { + return err + } + if count > 0 { + return errUsernameExists + } + checkQuery2 := rewritePlaceholders(db, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE email = ?`, a.tableNames.Users)) + if err := db.QueryRowContext(ctx, checkQuery2, req.Email).Scan(&count); err != nil { + return err + } + if count > 0 { + return errEmailExists + } + + insertQuery := rewritePlaceholders(db, fmt.Sprintf( + `INSERT INTO %s (username, email, password, user_level, roles, is_active, created_at, updated_at, program_user_id, program_user_table) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + a.tableNames.Users)) + res, err := db.ExecContext(ctx, insertQuery, req.Username, req.Email, req.Password, req.UserLevel, rolesStr, true, now, now, 0, "") + if err != nil { + return err + } + userID, err = res.LastInsertId() + return err + }) + if err != nil { + if errors.Is(err, errUsernameExists) { + return nil, errUsernameExists + } + if errors.Is(err, errEmailExists) { + return nil, errEmailExists + } + return nil, fmt.Errorf("register query failed: %w", err) + } + + sessionToken, err := generateSessionToken() + if err != nil { + return nil, fmt.Errorf("failed to generate session token: %w", err) + } + expiresAt := now.Add(24 * time.Hour) + + err = a.runDBOpWithReconnect(func(db *sql.DB) error { + insertSession := rewritePlaceholders(db, fmt.Sprintf( + `INSERT INTO %s (session_token, user_id, expires_at, ip_address, user_agent, last_activity_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + a.tableNames.UserSessions)) + if _, err := db.ExecContext(ctx, insertSession, sessionToken, userID, expiresAt, ipAddress, userAgent, now, now); err != nil { + return err + } + updUser := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET last_login_at = ? WHERE id = ?`, a.tableNames.Users)) + _, err := db.ExecContext(ctx, updUser, now, userID) + return err + }) + if err != nil { + return nil, fmt.Errorf("register query failed: %w", err) + } + + userCtx := &UserContext{ + UserID: int(userID), + UserName: req.Username, + Email: req.Email, + UserLevel: req.UserLevel, + Roles: parseRoles(rolesStr), + SessionID: sessionToken, + ProgramUserID: 0, + ProgramUserTable: "", + } + + return &LoginResponse{ + Token: sessionToken, + User: userCtx, + ExpiresIn: 86400, + }, nil +} + +func (a *DatabaseAuthenticator) logoutDirect(ctx context.Context, req LogoutRequest) error { + token := req.Token + token = strings.TrimPrefix(token, "Bearer ") + token = strings.TrimPrefix(token, "bearer ") + + var rows int64 + err := a.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE session_token = ? AND user_id = ?`, a.tableNames.UserSessions)) + res, err := db.ExecContext(ctx, query, token, req.UserID) + if err != nil { + return err + } + rows, err = res.RowsAffected() + return err + }) + if err != nil { + return fmt.Errorf("logout query failed: %w", err) + } + if rows == 0 { + return fmt.Errorf("session not found") + } + + if req.Token != "" { + cacheKey := fmt.Sprintf("auth:session:%s", req.Token) + _ = a.cache.Delete(ctx, cacheKey) + } + return nil +} + +func (a *DatabaseAuthenticator) sessionDirect(ctx context.Context, token string) (*UserContext, error) { + var userID int + var username, email, roles, programUserTable sql.NullString + var userLevel, programUserID sql.NullInt64 + + err := a.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf( + `SELECT s.user_id, u.username, u.email, u.user_level, u.roles, u.program_user_id, u.program_user_table + FROM %s s JOIN %s u ON s.user_id = u.id + WHERE s.session_token = ? AND s.expires_at > ? AND u.is_active = ?`, + a.tableNames.UserSessions, a.tableNames.Users)) + return db.QueryRowContext(ctx, query, token, time.Now(), true).Scan(&userID, &username, &email, &userLevel, &roles, &programUserID, &programUserTable) + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("invalid or expired session") + } + return nil, fmt.Errorf("session query failed: %w", err) + } + + return &UserContext{ + UserID: userID, + UserName: username.String, + Email: email.String, + UserLevel: int(userLevel.Int64), + SessionID: token, + Roles: parseRoles(roles.String), + ProgramUserID: int(programUserID.Int64), + ProgramUserTable: programUserTable.String, + }, nil +} + +func (a *DatabaseAuthenticator) updateSessionActivityDirect(ctx context.Context, sessionToken string) error { + return a.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET last_activity_at = ? WHERE session_token = ? AND expires_at > ?`, a.tableNames.UserSessions)) + _, err := db.ExecContext(ctx, query, time.Now(), sessionToken, time.Now()) + return err + }) +} + +func (a *DatabaseAuthenticator) refreshTokenDirect(ctx context.Context, oldToken string) (*LoginResponse, error) { + var userID int + var username, email, roles, ipAddress, userAgent, programUserTable sql.NullString + var userLevel, programUserID sql.NullInt64 + + err := a.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf( + `SELECT s.user_id, u.username, u.email, u.user_level, u.roles, s.ip_address, s.user_agent, u.program_user_id, u.program_user_table + FROM %s s JOIN %s u ON s.user_id = u.id + WHERE s.session_token = ? AND s.expires_at > ? AND u.is_active = ?`, + a.tableNames.UserSessions, a.tableNames.Users)) + return db.QueryRowContext(ctx, query, oldToken, time.Now(), true).Scan(&userID, &username, &email, &userLevel, &roles, &ipAddress, &userAgent, &programUserID, &programUserTable) + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("invalid or expired refresh token") + } + return nil, fmt.Errorf("refresh token query failed: %w", err) + } + + newToken, err := generateSessionToken() + if err != nil { + return nil, fmt.Errorf("failed to generate session token: %w", err) + } + now := time.Now() + expiresAt := now.Add(24 * time.Hour) + + err = a.runDBOpWithReconnect(func(db *sql.DB) error { + insertQuery := rewritePlaceholders(db, fmt.Sprintf( + `INSERT INTO %s (session_token, user_id, expires_at, ip_address, user_agent, last_activity_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + a.tableNames.UserSessions)) + if _, err := db.ExecContext(ctx, insertQuery, newToken, userID, expiresAt, ipAddress.String, userAgent.String, now, now); err != nil { + return err + } + delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE session_token = ?`, a.tableNames.UserSessions)) + _, err := db.ExecContext(ctx, delQuery, oldToken) + return err + }) + if err != nil { + return nil, fmt.Errorf("refresh token generation failed: %w", err) + } + + userCtx := &UserContext{ + UserID: userID, + UserName: username.String, + Email: email.String, + UserLevel: int(userLevel.Int64), + SessionID: newToken, + Roles: parseRoles(roles.String), + ProgramUserID: int(programUserID.Int64), + ProgramUserTable: programUserTable.String, + } + + return &LoginResponse{ + Token: newToken, + User: userCtx, + ExpiresIn: int64(24 * time.Hour.Seconds()), + }, nil +} + +func (a *DatabaseAuthenticator) requestPasswordResetDirect(ctx context.Context, req PasswordResetRequest) (*PasswordResetResponse, error) { + if req.Email == "" && req.Username == "" { + return nil, fmt.Errorf("email or username is required") + } + + var userID int + err := a.runDBOpWithReconnect(func(db *sql.DB) error { + var query string + var arg string + if req.Email != "" { + query = rewritePlaceholders(db, fmt.Sprintf(`SELECT id FROM %s WHERE email = ? AND is_active = ?`, a.tableNames.Users)) + arg = req.Email + } else { + query = rewritePlaceholders(db, fmt.Sprintf(`SELECT id FROM %s WHERE username = ? AND is_active = ?`, a.tableNames.Users)) + arg = req.Username + } + return db.QueryRowContext(ctx, query, arg, true).Scan(&userID) + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // Return generic success even when user not found to avoid user enumeration. + return &PasswordResetResponse{Token: "", ExpiresIn: 0}, nil + } + return nil, fmt.Errorf("password reset request query failed: %w", err) + } + + rawBytes := make([]byte, 32) + if _, err := rand.Read(rawBytes); err != nil { + return nil, fmt.Errorf("failed to generate reset token: %w", err) + } + rawToken := hex.EncodeToString(rawBytes) + hash := sha256.Sum256([]byte(rawToken)) + tokenHash := hex.EncodeToString(hash[:]) + now := time.Now() + expiresAt := now.Add(1 * time.Hour) + + err = a.runDBOpWithReconnect(func(db *sql.DB) error { + delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ? AND used = ?`, a.tableNames.UserPasswordResets)) + if _, err := db.ExecContext(ctx, delQuery, userID, false); err != nil { + return err + } + insQuery := rewritePlaceholders(db, fmt.Sprintf(`INSERT INTO %s (user_id, token_hash, expires_at, created_at, used) VALUES (?, ?, ?, ?, ?)`, a.tableNames.UserPasswordResets)) + _, err := db.ExecContext(ctx, insQuery, userID, tokenHash, expiresAt, now, false) + return err + }) + if err != nil { + return nil, fmt.Errorf("password reset request query failed: %w", err) + } + + return &PasswordResetResponse{Token: rawToken, ExpiresIn: 3600}, nil +} + +func (a *DatabaseAuthenticator) completePasswordResetDirect(ctx context.Context, req PasswordResetCompleteRequest) error { + if req.Token == "" { + return fmt.Errorf("token is required") + } + if req.NewPassword == "" { + return fmt.Errorf("new_password is required") + } + + hash := sha256.Sum256([]byte(req.Token)) + tokenHash := hex.EncodeToString(hash[:]) + + var resetID, userID int + var expiresAt time.Time + err := a.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf(`SELECT id, user_id, expires_at FROM %s WHERE token_hash = ? AND used = ?`, a.tableNames.UserPasswordResets)) + return db.QueryRowContext(ctx, query, tokenHash, false).Scan(&resetID, &userID, &expiresAt) + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("invalid or expired token") + } + return fmt.Errorf("password reset complete query failed: %w", err) + } + if !expiresAt.After(time.Now()) { + return fmt.Errorf("invalid or expired token") + } + + now := time.Now() + err = a.runDBOpWithReconnect(func(db *sql.DB) error { + updUser := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET password = ?, updated_at = ? WHERE id = ?`, a.tableNames.Users)) + if _, err := db.ExecContext(ctx, updUser, req.NewPassword, now, userID); err != nil { + return err + } + delSessions := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, a.tableNames.UserSessions)) + if _, err := db.ExecContext(ctx, delSessions, userID); err != nil { + return err + } + updReset := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET used = ?, used_at = ? WHERE id = ?`, a.tableNames.UserPasswordResets)) + _, err := db.ExecContext(ctx, updReset, true, now, resetID) + return err + }) + if err != nil { + return fmt.Errorf("password reset complete query failed: %w", err) + } + return nil +} + +// claimStrings extracts ip_address/user_agent from a request's Claims map, mirroring +// p_request->'claims'->>'ip_address' / 'user_agent' in the plpgsql procedures. +func claimStrings(claims map[string]any) (ipAddress, userAgent string) { + if claims == nil { + return "", "" + } + if v, ok := claims["ip_address"].(string); ok { + ipAddress = v + } + if v, ok := claims["user_agent"].(string); ok { + userAgent = v + } + return ipAddress, userAgent +} + +// jwtLoginDirect mirrors resolvespec_jwt_login. +func (a *JWTAuthenticator) jwtLoginDirect(ctx context.Context, req LoginRequest) (*LoginResponse, error) { + var userID int + var email, roles sql.NullString + var userLevel sql.NullInt64 + + runQuery := func() error { + query := rewritePlaceholders(a.getDB(), fmt.Sprintf(`SELECT id, email, user_level, roles FROM %s WHERE username = ? AND is_active = ?`, a.tableNames.Users)) + return a.getDB().QueryRowContext(ctx, query, req.Username, true).Scan(&userID, &email, &userLevel, &roles) + } + err := runQuery() + if isDBClosed(err) { + if reconnErr := a.reconnectDB(); reconnErr == nil { + err = runQuery() + } + } + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("invalid credentials") + } + return nil, fmt.Errorf("login query failed: %w", err) + } + + expiresAt := time.Now().Add(24 * time.Hour) + tokenString := fmt.Sprintf("token_%d_%d", userID, expiresAt.Unix()) + + return &LoginResponse{ + Token: tokenString, + User: &UserContext{ + UserID: userID, + UserName: req.Username, + Email: email.String, + UserLevel: int(userLevel.Int64), + Roles: parseRoles(roles.String), + }, + ExpiresIn: int64(24 * time.Hour.Seconds()), + }, nil +} + +// jwtLogoutDirect mirrors resolvespec_jwt_logout (adds token to the blacklist table). +func (a *JWTAuthenticator) jwtLogoutDirect(ctx context.Context, req LogoutRequest) error { + db := a.getDB() + expiresAt := time.Now().Add(24 * time.Hour) + query := rewritePlaceholders(db, fmt.Sprintf(`INSERT INTO %s (token, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)`, a.tableNames.TokenBlacklist)) + _, err := db.ExecContext(ctx, query, req.Token, req.UserID, expiresAt, time.Now()) + if err != nil { + return fmt.Errorf("logout query failed: %w", err) + } + return nil +} diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/query_mode.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/query_mode.go new file mode 100644 index 0000000..9c040d1 --- /dev/null +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/query_mode.go @@ -0,0 +1,169 @@ +package security + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" + "strconv" + "strings" + "sync" + "time" +) + +// QueryMode selects how a provider talks to the database: via the configured +// resolvespec_* stored procedure (ModeProcedure), via portable Go/SQL logic +// (ModeDirect), or auto-detected per connection (ModeAuto, the default). +type QueryMode int + +const ( + // ModeAuto probes whether the configured stored procedure exists on a + // Postgres connection and uses it if so, otherwise falls back to Direct mode. + ModeAuto QueryMode = iota + // ModeProcedure always calls the configured resolvespec_* stored procedure. + ModeProcedure + // ModeDirect always uses the portable Go/SQL implementation, never the + // stored procedure. + ModeDirect +) + +// dbCapability probes and caches whether a given *sql.DB is Postgres and +// whether specific stored procedures exist on it. One instance is shared by +// a provider (DatabaseAuthenticator, DatabaseTwoFactorProvider, etc.) across +// all of its operations. +type dbCapability struct { + funcExists sync.Map // procName (string) -> exists (bool) +} + +// newDBCapability creates a new, empty capability cache. +func newDBCapability() *dbCapability { + return &dbCapability{} +} + +// reset clears all cached probe results. Call after reconnecting to a +// (possibly different) database. +func (c *dbCapability) reset() { + c.funcExists.Range(func(key, _ any) bool { + c.funcExists.Delete(key) + return true + }) +} + +// probeFunctionExists checks, via a Postgres-specific system catalog query, +// whether a function named procName exists. Any error (wrong dialect, +// placeholder syntax rejected, relation missing, etc.) is treated as "does +// not exist" rather than propagated - the probe must never be able to panic +// or block resolution of the query mode. +func probeFunctionExists(ctx context.Context, db *sql.DB, procName string) bool { + if db == nil { + return false + } + var exists bool + defer func() { + // Guard against any unexpected panic from a misbehaving driver. + _ = recover() + }() + row := db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM pg_proc WHERE proname = $1 LIMIT 1)`, procName) + if err := row.Scan(&exists); err != nil { + return false + } + return exists +} + +// ShouldUseProcedure decides whether the stored-procedure code path should be +// used for procName given mode. ModeProcedure/ModeDirect are unconditional. +// +// ModeAuto resolves as follows: +// - A recognized portable-only driver (SQLite, MySQL) always uses Direct +// mode; no query is issued. +// - A recognized Postgres driver (lib/pq, pgx) probes pg_proc for procName +// and uses the stored procedure only if it actually exists there. +// - Any other/unrecognized driver (including test doubles such as +// sqlmock) cannot be safely dialect-probed without risking an +// unexpected query against a strictly-ordered mock, so it defaults to +// the stored-procedure path, preserving pre-existing behavior for +// callers that configure Postgres-flavored access without an +// identifiable driver type. +func (c *dbCapability) ShouldUseProcedure(ctx context.Context, mode QueryMode, db *sql.DB, procName string) bool { + switch mode { + case ModeProcedure: + return true + case ModeDirect: + return false + default: + if cached, ok := c.funcExists.Load(procName); ok { + return cached.(bool) + } + + var exists bool + switch { + case driverIsPortableOnly(db): + exists = false + case driverIsPostgres(db): + exists = probeFunctionExists(ctx, db, procName) + default: + exists = true + } + + c.funcExists.Store(procName, exists) + return exists + } +} + +// driverIsPostgres reports whether db's underlying driver looks like a +// Postgres driver (lib/pq or pgx), based on the driver's Go type name. +func driverIsPostgres(db *sql.DB) bool { + if db == nil { + return false + } + t := strings.ToLower(fmt.Sprintf("%T", db.Driver())) + return strings.Contains(t, "pq.") || strings.Contains(t, "pgx") || strings.Contains(t, "postgres") +} + +// driverIsPortableOnly reports whether db's underlying driver is a dialect +// that never has the resolvespec_* Postgres functions available (SQLite, +// MySQL), so ModeAuto can skip probing entirely and go straight to Direct. +func driverIsPortableOnly(db *sql.DB) bool { + if db == nil { + return false + } + t := strings.ToLower(fmt.Sprintf("%T", db.Driver())) + return strings.Contains(t, "sqlite") || strings.Contains(t, "mysql") +} + +// rewritePlaceholders converts a query written with "?" placeholders +// (SQLite/MySQL style) to Postgres "$1", "$2", ... style when db's driver is +// Postgres. All Direct-mode SQL in this package is written with "?" and +// passed through this helper before execution so the same query source works +// against SQLite, MySQL, and (in the rare fallback case) Postgres without the +// resolvespec_* functions installed. +func rewritePlaceholders(db *sql.DB, query string) string { + if !driverIsPostgres(db) { + return query + } + var b strings.Builder + n := 0 + for _, r := range query { + if r == '?' { + n++ + b.WriteString("$") + b.WriteString(strconv.Itoa(n)) + } else { + b.WriteRune(r) + } + } + return b.String() +} + +// generateSessionToken produces a session token in the same shape the +// plpgsql stored procedures generate: "sess_" + hex(32 random bytes) + "_" + +// unix timestamp, so downstream code that parses/displays tokens is +// unaffected by which mode created them. +func generateSessionToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return fmt.Sprintf("sess_%s_%d", hex.EncodeToString(buf), time.Now().Unix()), nil +} diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/table_names.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/table_names.go new file mode 100644 index 0000000..2a78d4c --- /dev/null +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/table_names.go @@ -0,0 +1,101 @@ +package security + +import ( + "errors" + "fmt" + "reflect" +) + +// ErrDirectModeUnsupported is returned by Direct-mode operations that have no +// portable equivalent because they depend on an external schema this package +// does not own (e.g. core.secaccess / core.hub_link for column/row security). +// Callers needing that functionality must run against Postgres with the +// resolvespec_column_security / resolvespec_row_security stored procedures +// installed (ModeProcedure or ModeAuto with the procedures present). +var ErrDirectModeUnsupported = errors.New("direct mode does not support column/row security; requires the resolvespec_column_security/resolvespec_row_security stored procedures") + +// TableNames defines all configurable table names used by Direct-mode SQL +// in the security package. Override individual fields to remap to custom +// table names. Use DefaultTableNames() for baseline defaults, and +// MergeTableNames() to apply partial overrides. +type TableNames struct { + Users string // default: "users" + UserSessions string // default: "user_sessions" + TokenBlacklist string // default: "token_blacklist" + UserTOTPBackupCodes string // default: "user_totp_backup_codes" + UserPasskeyCredentials string // default: "user_passkey_credentials" + UserPasswordResets string // default: "user_password_resets" + OAuthClients string // default: "oauth_clients" + OAuthCodes string // default: "oauth_codes" +} + +// DefaultTableNames returns a TableNames with all default table names. +func DefaultTableNames() *TableNames { + return &TableNames{ + Users: "users", + UserSessions: "user_sessions", + TokenBlacklist: "token_blacklist", + UserTOTPBackupCodes: "user_totp_backup_codes", + UserPasskeyCredentials: "user_passkey_credentials", + UserPasswordResets: "user_password_resets", + OAuthClients: "oauth_clients", + OAuthCodes: "oauth_codes", + } +} + +// MergeTableNames returns a copy of base with any non-empty fields from override applied. +// If override is nil, a copy of base is returned. +func MergeTableNames(base, override *TableNames) *TableNames { + if override == nil { + copied := *base + return &copied + } + merged := *base + if override.Users != "" { + merged.Users = override.Users + } + if override.UserSessions != "" { + merged.UserSessions = override.UserSessions + } + if override.TokenBlacklist != "" { + merged.TokenBlacklist = override.TokenBlacklist + } + if override.UserTOTPBackupCodes != "" { + merged.UserTOTPBackupCodes = override.UserTOTPBackupCodes + } + if override.UserPasskeyCredentials != "" { + merged.UserPasskeyCredentials = override.UserPasskeyCredentials + } + if override.UserPasswordResets != "" { + merged.UserPasswordResets = override.UserPasswordResets + } + if override.OAuthClients != "" { + merged.OAuthClients = override.OAuthClients + } + if override.OAuthCodes != "" { + merged.OAuthCodes = override.OAuthCodes + } + return &merged +} + +// ValidateTableNames checks that all non-empty fields in names are valid SQL identifiers. +func ValidateTableNames(names *TableNames) error { + v := reflect.ValueOf(names).Elem() + typ := v.Type() + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + if field.Kind() != reflect.String { + continue + } + val := field.String() + if val != "" && !validSQLIdentifier.MatchString(val) { + return fmt.Errorf("TableNames.%s contains invalid characters: %q", typ.Field(i).Name, val) + } + } + return nil +} + +// resolveTableNames merges an optional override with defaults. +func resolveTableNames(override *TableNames) *TableNames { + return MergeTableNames(DefaultTableNames(), override) +} diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/totp_provider_database.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/totp_provider_database.go index 6fe8c5e..17c306d 100644 --- a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/totp_provider_database.go +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/totp_provider_database.go @@ -1,20 +1,27 @@ package security import ( + "context" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "fmt" + "sync" ) // DatabaseTwoFactorProvider implements TwoFactorAuthProvider using PostgreSQL stored procedures // Procedure names are configurable via SQLNames (see DefaultSQLNames for defaults) // See totp_database_schema.sql for procedure definitions type DatabaseTwoFactorProvider struct { - db *sql.DB - totpGen *TOTPGenerator - sqlNames *SQLNames + db *sql.DB + dbMu sync.RWMutex + dbFactory func() (*sql.DB, error) + totpGen *TOTPGenerator + sqlNames *SQLNames + tableNames *TableNames + queryMode QueryMode + capability *dbCapability } // NewDatabaseTwoFactorProvider creates a new database-backed 2FA provider @@ -23,12 +30,69 @@ func NewDatabaseTwoFactorProvider(db *sql.DB, config *TwoFactorConfig, names ... config = DefaultTwoFactorConfig() } return &DatabaseTwoFactorProvider{ - db: db, - totpGen: NewTOTPGenerator(config), - sqlNames: resolveSQLNames(names...), + db: db, + totpGen: NewTOTPGenerator(config), + sqlNames: resolveSQLNames(names...), + tableNames: DefaultTableNames(), + capability: newDBCapability(), } } +// WithDBFactory configures a factory used to reopen the database connection if it is closed. +func (p *DatabaseTwoFactorProvider) WithDBFactory(factory func() (*sql.DB, error)) *DatabaseTwoFactorProvider { + p.dbFactory = factory + return p +} + +// WithTableNames configures Direct-mode table names. If names is nil, defaults are used. +func (p *DatabaseTwoFactorProvider) WithTableNames(names *TableNames) *DatabaseTwoFactorProvider { + p.tableNames = resolveTableNames(names) + return p +} + +// WithQueryMode selects stored-procedure vs Direct-mode SQL (default ModeAuto). +func (p *DatabaseTwoFactorProvider) WithQueryMode(mode QueryMode) *DatabaseTwoFactorProvider { + p.queryMode = mode + return p +} + +func (p *DatabaseTwoFactorProvider) getDB() *sql.DB { + p.dbMu.RLock() + defer p.dbMu.RUnlock() + return p.db +} + +func (p *DatabaseTwoFactorProvider) reconnectDB() error { + if p.dbFactory == nil { + return fmt.Errorf("no db factory configured for reconnect") + } + newDB, err := p.dbFactory() + if err != nil { + return err + } + p.dbMu.Lock() + p.db = newDB + p.dbMu.Unlock() + if p.capability != nil { + p.capability.reset() + } + return nil +} + +func (p *DatabaseTwoFactorProvider) runDBOpWithReconnect(run func(*sql.DB) error) error { + db := p.getDB() + if db == nil { + return fmt.Errorf("database connection is nil") + } + err := run(db) + if isDBClosed(err) { + if reconnErr := p.reconnectDB(); reconnErr == nil { + err = run(p.getDB()) + } + } + return err +} + // Generate2FASecret creates a new secret for a user func (p *DatabaseTwoFactorProvider) Generate2FASecret(userID int, issuer, accountName string) (*TwoFactorSecret, error) { secret, err := p.totpGen.GenerateSecret() @@ -72,12 +136,17 @@ func (p *DatabaseTwoFactorProvider) Enable2FA(userID int, secret string, backupC return fmt.Errorf("failed to marshal backup codes: %w", err) } + ctx := context.Background() + if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.TOTPEnable) { + return p.enable2FADirect(ctx, userID, secret, hashedCodes) + } + // Call stored procedure var success bool var errorMsg sql.NullString query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1, $2, $3::jsonb)`, p.sqlNames.TOTPEnable) - err = p.db.QueryRow(query, userID, secret, string(codesJSON)).Scan(&success, &errorMsg) + err = p.getDB().QueryRow(query, userID, secret, string(codesJSON)).Scan(&success, &errorMsg) if err != nil { return fmt.Errorf("enable 2FA query failed: %w", err) } @@ -94,11 +163,16 @@ func (p *DatabaseTwoFactorProvider) Enable2FA(userID int, secret string, backupC // Disable2FA deactivates 2FA for a user func (p *DatabaseTwoFactorProvider) Disable2FA(userID int) error { + ctx := context.Background() + if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.TOTPDisable) { + return p.disable2FADirect(ctx, userID) + } + var success bool var errorMsg sql.NullString query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1)`, p.sqlNames.TOTPDisable) - err := p.db.QueryRow(query, userID).Scan(&success, &errorMsg) + err := p.getDB().QueryRow(query, userID).Scan(&success, &errorMsg) if err != nil { return fmt.Errorf("disable 2FA query failed: %w", err) } @@ -115,12 +189,17 @@ func (p *DatabaseTwoFactorProvider) Disable2FA(userID int) error { // Get2FAStatus checks if user has 2FA enabled func (p *DatabaseTwoFactorProvider) Get2FAStatus(userID int) (bool, error) { + ctx := context.Background() + if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.TOTPGetStatus) { + return p.get2FAStatusDirect(ctx, userID) + } + var success bool var errorMsg sql.NullString var enabled bool query := fmt.Sprintf(`SELECT p_success, p_error, p_enabled FROM %s($1)`, p.sqlNames.TOTPGetStatus) - err := p.db.QueryRow(query, userID).Scan(&success, &errorMsg, &enabled) + err := p.getDB().QueryRow(query, userID).Scan(&success, &errorMsg, &enabled) if err != nil { return false, fmt.Errorf("get 2FA status query failed: %w", err) } @@ -137,12 +216,17 @@ func (p *DatabaseTwoFactorProvider) Get2FAStatus(userID int) (bool, error) { // Get2FASecret retrieves the user's 2FA secret func (p *DatabaseTwoFactorProvider) Get2FASecret(userID int) (string, error) { + ctx := context.Background() + if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.TOTPGetSecret) { + return p.get2FASecretDirect(ctx, userID) + } + var success bool var errorMsg sql.NullString var secret sql.NullString query := fmt.Sprintf(`SELECT p_success, p_error, p_secret FROM %s($1)`, p.sqlNames.TOTPGetSecret) - err := p.db.QueryRow(query, userID).Scan(&success, &errorMsg, &secret) + err := p.getDB().QueryRow(query, userID).Scan(&success, &errorMsg, &secret) if err != nil { return "", fmt.Errorf("get 2FA secret query failed: %w", err) } @@ -175,6 +259,14 @@ func (p *DatabaseTwoFactorProvider) GenerateBackupCodes(userID int, count int) ( hashedCodes[i] = hex.EncodeToString(hash[:]) } + ctx := context.Background() + if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.TOTPRegenerateBackup) { + if err := p.regenerateBackupCodesDirect(ctx, userID, hashedCodes); err != nil { + return nil, err + } + return codes, nil + } + // Convert to JSON array codesJSON, err := json.Marshal(hashedCodes) if err != nil { @@ -186,7 +278,7 @@ func (p *DatabaseTwoFactorProvider) GenerateBackupCodes(userID int, count int) ( var errorMsg sql.NullString query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1, $2::jsonb)`, p.sqlNames.TOTPRegenerateBackup) - err = p.db.QueryRow(query, userID, string(codesJSON)).Scan(&success, &errorMsg) + err = p.getDB().QueryRow(query, userID, string(codesJSON)).Scan(&success, &errorMsg) if err != nil { return nil, fmt.Errorf("regenerate backup codes query failed: %w", err) } @@ -208,12 +300,17 @@ func (p *DatabaseTwoFactorProvider) ValidateBackupCode(userID int, code string) hash := sha256.Sum256([]byte(code)) codeHash := hex.EncodeToString(hash[:]) + ctx := context.Background() + if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.TOTPValidateBackupCode) { + return p.validateBackupCodeDirect(ctx, userID, codeHash) + } + var success bool var errorMsg sql.NullString var valid bool query := fmt.Sprintf(`SELECT p_success, p_error, p_valid FROM %s($1, $2)`, p.sqlNames.TOTPValidateBackupCode) - err := p.db.QueryRow(query, userID, codeHash).Scan(&success, &errorMsg, &valid) + err := p.getDB().QueryRow(query, userID, codeHash).Scan(&success, &errorMsg, &valid) if err != nil { return false, fmt.Errorf("validate backup code query failed: %w", err) } diff --git a/vendor/github.com/bitechdev/ResolveSpec/pkg/security/totp_provider_database_direct.go b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/totp_provider_database_direct.go new file mode 100644 index 0000000..500c4c2 --- /dev/null +++ b/vendor/github.com/bitechdev/ResolveSpec/pkg/security/totp_provider_database_direct.go @@ -0,0 +1,151 @@ +package security + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// Direct-mode implementations mirroring the resolvespec_totp_* stored +// procedures in database_schema.sql using plain SQL against TableNames. + +func (p *DatabaseTwoFactorProvider) enable2FADirect(ctx context.Context, userID int, secret string, hashedCodes []string) error { + return p.runDBOpWithReconnect(func(db *sql.DB) error { + updQuery := rewritePlaceholders(db, fmt.Sprintf( + `UPDATE %s SET totp_secret = ?, totp_enabled = ?, totp_enabled_at = ? WHERE id = ?`, p.tableNames.Users)) + res, err := db.ExecContext(ctx, updQuery, secret, true, time.Now(), userID) + if err != nil { + return err + } + if rows, err := res.RowsAffected(); err != nil { + return err + } else if rows == 0 { + return fmt.Errorf("user not found") + } + + delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes)) + if _, err := db.ExecContext(ctx, delQuery, userID); err != nil { + return err + } + + insQuery := rewritePlaceholders(db, fmt.Sprintf(`INSERT INTO %s (user_id, code_hash) VALUES (?, ?)`, p.tableNames.UserTOTPBackupCodes)) + for _, hash := range hashedCodes { + if _, err := db.ExecContext(ctx, insQuery, userID, hash); err != nil { + return err + } + } + return nil + }) +} + +func (p *DatabaseTwoFactorProvider) disable2FADirect(ctx context.Context, userID int) error { + return p.runDBOpWithReconnect(func(db *sql.DB) error { + updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET totp_secret = NULL, totp_enabled = ? WHERE id = ?`, p.tableNames.Users)) + res, err := db.ExecContext(ctx, updQuery, false, userID) + if err != nil { + return err + } + if rows, err := res.RowsAffected(); err != nil { + return err + } else if rows == 0 { + return fmt.Errorf("user not found") + } + + delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes)) + _, err = db.ExecContext(ctx, delQuery, userID) + return err + }) +} + +func (p *DatabaseTwoFactorProvider) get2FAStatusDirect(ctx context.Context, userID int) (bool, error) { + var enabled sql.NullBool + err := p.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf(`SELECT totp_enabled FROM %s WHERE id = ?`, p.tableNames.Users)) + return db.QueryRowContext(ctx, query, userID).Scan(&enabled) + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, fmt.Errorf("user not found") + } + return false, fmt.Errorf("get 2FA status query failed: %w", err) + } + return enabled.Bool, nil +} + +func (p *DatabaseTwoFactorProvider) get2FASecretDirect(ctx context.Context, userID int) (string, error) { + var secret sql.NullString + var enabled sql.NullBool + err := p.runDBOpWithReconnect(func(db *sql.DB) error { + query := rewritePlaceholders(db, fmt.Sprintf(`SELECT totp_secret, totp_enabled FROM %s WHERE id = ?`, p.tableNames.Users)) + return db.QueryRowContext(ctx, query, userID).Scan(&secret, &enabled) + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("user not found") + } + return "", fmt.Errorf("get 2FA secret query failed: %w", err) + } + if !enabled.Bool { + return "", fmt.Errorf("TOTP not enabled for user") + } + return secret.String, nil +} + +func (p *DatabaseTwoFactorProvider) regenerateBackupCodesDirect(ctx context.Context, userID int, hashedCodes []string) error { + return p.runDBOpWithReconnect(func(db *sql.DB) error { + var count int + checkQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE id = ? AND totp_enabled = ?`, p.tableNames.Users)) + if err := db.QueryRowContext(ctx, checkQuery, userID, true).Scan(&count); err != nil { + return err + } + if count == 0 { + return fmt.Errorf("user not found or TOTP not enabled") + } + + delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes)) + if _, err := db.ExecContext(ctx, delQuery, userID); err != nil { + return err + } + + insQuery := rewritePlaceholders(db, fmt.Sprintf(`INSERT INTO %s (user_id, code_hash) VALUES (?, ?)`, p.tableNames.UserTOTPBackupCodes)) + for _, hash := range hashedCodes { + if _, err := db.ExecContext(ctx, insQuery, userID, hash); err != nil { + return err + } + } + return nil + }) +} + +func (p *DatabaseTwoFactorProvider) validateBackupCodeDirect(ctx context.Context, userID int, codeHash string) (bool, error) { + var valid bool + err := p.runDBOpWithReconnect(func(db *sql.DB) error { + var codeID int + var used bool + query := rewritePlaceholders(db, fmt.Sprintf(`SELECT id, used FROM %s WHERE user_id = ? AND code_hash = ?`, p.tableNames.UserTOTPBackupCodes)) + err := db.QueryRowContext(ctx, query, userID, codeHash).Scan(&codeID, &used) + if errors.Is(err, sql.ErrNoRows) { + valid = false + return nil + } + if err != nil { + return err + } + if used { + return fmt.Errorf("backup code already used") + } + + updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET used = ?, used_at = ? WHERE id = ?`, p.tableNames.UserTOTPBackupCodes)) + if _, err := db.ExecContext(ctx, updQuery, true, time.Now(), codeID); err != nil { + return err + } + valid = true + return nil + }) + if err != nil { + return false, err + } + return valid, nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index b9291d5..3a131d8 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,7 +1,7 @@ # github.com/beorn7/perks v1.0.1 ## explicit; go 1.11 github.com/beorn7/perks/quantile -# github.com/bitechdev/ResolveSpec v1.1.24 +# github.com/bitechdev/ResolveSpec v1.1.26 ## explicit; go 1.25.7 github.com/bitechdev/ResolveSpec/pkg/cache github.com/bitechdev/ResolveSpec/pkg/common