diff --git a/README.md b/README.md index e1a3403..3fbb8bf 100644 --- a/README.md +++ b/README.md @@ -524,9 +524,9 @@ For documentation, see [pkg/cache/README.md](pkg/cache/README.md). #### Security -Authentication and authorization framework with hooks integration. +Authentication and authorization framework with hooks integration. Database-backed providers use PostgreSQL stored procedures by default, with a portable Direct mode (plain Go/SQL) for SQLite, MySQL, or Postgres without the procedures installed. -For documentation, see [pkg/security/README.md](pkg/security/README.md). +For documentation, see [pkg/security/README.md](pkg/security/README.md) (see "Direct Mode" for the SQLite/portable-SQL path). #### Middleware diff --git a/pkg/security/README.md b/pkg/security/README.md index 5471a3c..0fd0a69 100644 --- a/pkg/security/README.md +++ b/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/pkg/security/database_schema_sqlite.sql b/pkg/security/database_schema_sqlite.sql new file mode 100644 index 0000000..32ab3c6 --- /dev/null +++ b/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/pkg/security/direct_mode_test.go b/pkg/security/direct_mode_test.go new file mode 100644 index 0000000..8673aaa --- /dev/null +++ b/pkg/security/direct_mode_test.go @@ -0,0 +1,467 @@ +package security + +import ( + "context" + "database/sql" + "encoding/base64" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +func futureTime() time.Time { + return time.Now().Add(1 * time.Hour) +} + +// newDirectTestDB opens a fresh in-memory SQLite database and applies the +// portable Direct-mode schema (database_schema_sqlite.sql), giving every +// Direct-mode test a real, isolated database to exercise end-to-end. +func newDirectTestDB(t *testing.T) *sql.DB { + t.Helper() + + db, err := sql.Open("sqlite3", "file::memory:?cache=shared") + if err != nil { + t.Fatalf("failed to open sqlite db: %v", err) + } + db.SetMaxOpenConns(1) // keep the shared in-memory db single-connection so state isn't lost + t.Cleanup(func() { _ = db.Close() }) + + schemaPath := filepath.Join("database_schema_sqlite.sql") + schema, err := os.ReadFile(schemaPath) + if err != nil { + t.Fatalf("failed to read schema: %v", err) + } + if _, err := db.Exec(string(schema)); err != nil { + t.Fatalf("failed to apply schema: %v", err) + } + return db +} + +func authenticatedRequest(token string) *http.Request { + req, _ := http.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer "+token) + return req +} + +func TestDirectMode_RegisterThenLogin(t *testing.T) { + db := newDirectTestDB(t) + auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect}) + ctx := context.Background() + + regResp, err := auth.Register(ctx, RegisterRequest{ + Username: "alice", + Password: "hunter2", + Email: "alice@example.com", + Roles: []string{"user", "admin"}, + Claims: map[string]any{"ip_address": "127.0.0.1", "user_agent": "test-agent"}, + }) + if err != nil { + t.Fatalf("Register() error = %v", err) + } + if regResp.Token == "" || regResp.User == nil { + t.Fatalf("Register() returned incomplete response: %+v", regResp) + } + if len(regResp.User.Roles) != 2 { + t.Errorf("expected 2 roles, got %v", regResp.User.Roles) + } + + loginResp, err := auth.Login(ctx, LoginRequest{Username: "alice", Password: "hunter2"}) + if err != nil { + t.Fatalf("Login() error = %v", err) + } + if loginResp.User.UserName != "alice" { + t.Errorf("Login() user = %q, want alice", loginResp.User.UserName) + } + + // Duplicate registration should fail. + if _, err := auth.Register(ctx, RegisterRequest{Username: "alice", Password: "x", Email: "other@example.com"}); err == nil { + t.Error("expected duplicate username registration to fail") + } +} + +func TestDirectMode_SessionLifecycle(t *testing.T) { + db := newDirectTestDB(t) + auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect}) + ctx := context.Background() + + loginResp, err := auth.Register(ctx, RegisterRequest{Username: "bob", Password: "p", Email: "bob@example.com"}) + if err != nil { + t.Fatalf("Register() error = %v", err) + } + + userCtx, err := auth.Authenticate(authenticatedRequest(loginResp.Token)) + if err != nil { + t.Fatalf("Authenticate() error = %v", err) + } + if userCtx.UserName != "bob" { + t.Errorf("Authenticate() user = %q, want bob", userCtx.UserName) + } + + refreshResp, err := auth.RefreshToken(ctx, loginResp.Token) + if err != nil { + t.Fatalf("RefreshToken() error = %v", err) + } + if refreshResp.Token == "" || refreshResp.Token == loginResp.Token { + t.Errorf("RefreshToken() should return a new token, got %q", refreshResp.Token) + } + + if err := auth.Logout(ctx, LogoutRequest{Token: refreshResp.Token, UserID: refreshResp.User.UserID}); err != nil { + t.Fatalf("Logout() error = %v", err) + } + + if _, err := auth.RefreshToken(ctx, refreshResp.Token); err == nil { + t.Error("expected RefreshToken() after logout to fail") + } +} + +func TestDirectMode_PasswordReset(t *testing.T) { + db := newDirectTestDB(t) + auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect}) + ctx := context.Background() + + if _, err := auth.Register(ctx, RegisterRequest{Username: "carol", Password: "old", Email: "carol@example.com"}); err != nil { + t.Fatalf("Register() error = %v", err) + } + + resetResp, err := auth.RequestPasswordReset(ctx, PasswordResetRequest{Email: "carol@example.com"}) + if err != nil { + t.Fatalf("RequestPasswordReset() error = %v", err) + } + if resetResp.Token == "" { + t.Fatal("expected a non-empty reset token") + } + + if err := auth.CompletePasswordReset(ctx, PasswordResetCompleteRequest{Token: resetResp.Token, NewPassword: "new"}); err != nil { + t.Fatalf("CompletePasswordReset() error = %v", err) + } + + // Reusing the same token should now fail. + if err := auth.CompletePasswordReset(ctx, PasswordResetCompleteRequest{Token: resetResp.Token, NewPassword: "again"}); err == nil { + t.Error("expected reusing a consumed reset token to fail") + } +} + +func TestDirectMode_JWTLoginAndLogout(t *testing.T) { + db := newDirectTestDB(t) + jwtAuth := NewJWTAuthenticator("secret", db).WithQueryMode(ModeDirect) + directAuth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect}) + ctx := context.Background() + + if _, err := directAuth.Register(ctx, RegisterRequest{Username: "dave", Password: "p", Email: "dave@example.com"}); err != nil { + t.Fatalf("Register() error = %v", err) + } + + resp, err := jwtAuth.Login(ctx, LoginRequest{Username: "dave", Password: "p"}) + if err != nil { + t.Fatalf("JWTAuthenticator.Login() error = %v", err) + } + if resp.User.UserName != "dave" { + t.Errorf("JWT login user = %q, want dave", resp.User.UserName) + } + + if err := jwtAuth.Logout(ctx, LogoutRequest{Token: resp.Token, UserID: resp.User.UserID}); err != nil { + t.Fatalf("JWTAuthenticator.Logout() error = %v", err) + } +} + +func TestDirectMode_TOTPEnableAndValidateBackupCode(t *testing.T) { + db := newDirectTestDB(t) + auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect}) + ctx := context.Background() + + regResp, err := auth.Register(ctx, RegisterRequest{Username: "erin", Password: "p", Email: "erin@example.com"}) + if err != nil { + t.Fatalf("Register() error = %v", err) + } + userID := regResp.User.UserID + + totp := NewDatabaseTwoFactorProvider(db, nil).WithQueryMode(ModeDirect) + + if err := totp.Enable2FA(userID, "SECRET123", []string{"code1", "code2"}); err != nil { + t.Fatalf("Enable2FA() error = %v", err) + } + + enabled, err := totp.Get2FAStatus(userID) + if err != nil { + t.Fatalf("Get2FAStatus() error = %v", err) + } + if !enabled { + t.Error("expected 2FA to be enabled") + } + + secret, err := totp.Get2FASecret(userID) + if err != nil { + t.Fatalf("Get2FASecret() error = %v", err) + } + if secret != "SECRET123" { + t.Errorf("Get2FASecret() = %q, want SECRET123", secret) + } + + valid, err := totp.ValidateBackupCode(userID, "code1") + if err != nil { + t.Fatalf("ValidateBackupCode() error = %v", err) + } + if !valid { + t.Error("expected backup code to be valid") + } + + // Reusing the same backup code should fail. + if _, err := totp.ValidateBackupCode(userID, "code1"); err == nil { + t.Error("expected reusing a consumed backup code to fail") + } + + if err := totp.Disable2FA(userID); err != nil { + t.Fatalf("Disable2FA() error = %v", err) + } + enabled, err = totp.Get2FAStatus(userID) + if err != nil { + t.Fatalf("Get2FAStatus() error = %v", err) + } + if enabled { + t.Error("expected 2FA to be disabled") + } +} + +func TestDirectMode_PasskeyStoreAndFetch(t *testing.T) { + db := newDirectTestDB(t) + auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect}) + ctx := context.Background() + + regResp, err := auth.Register(ctx, RegisterRequest{Username: "frank", Password: "p", Email: "frank@example.com"}) + if err != nil { + t.Fatalf("Register() error = %v", err) + } + userID := regResp.User.UserID + + passkeys := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{ + RPID: "example.com", RPName: "Example", RPOrigin: "https://example.com", QueryMode: ModeDirect, + }) + + cred, err := passkeys.CompleteRegistration(ctx, userID, PasskeyRegistrationResponse{ + RawID: []byte("credential-1"), + Response: PasskeyAuthenticatorAttestationResponse{ + AttestationObject: []byte("public-key-bytes"), + }, + Transports: []string{"internal", "usb"}, + }, nil) + if err != nil { + t.Fatalf("CompleteRegistration() error = %v", err) + } + if cred.UserID != userID { + t.Errorf("CompleteRegistration() UserID = %d, want %d", cred.UserID, userID) + } + + creds, err := passkeys.GetCredentials(ctx, userID) + if err != nil { + t.Fatalf("GetCredentials() error = %v", err) + } + if len(creds) != 1 { + t.Fatalf("expected 1 credential, got %d", len(creds)) + } + if string(creds[0].CredentialID) != "credential-1" { + t.Errorf("GetCredentials() CredentialID = %q, want credential-1", creds[0].CredentialID) + } + if len(creds[0].Transports) != 2 { + t.Errorf("expected 2 transports, got %v", creds[0].Transports) + } + + credentialIDB64 := base64.StdEncoding.EncodeToString(creds[0].CredentialID) + + if err := passkeys.UpdateCredentialName(ctx, userID, credentialIDB64, "My Phone"); err != nil { + t.Fatalf("UpdateCredentialName() error = %v", err) + } + + updated, err := passkeys.GetCredentials(ctx, userID) + if err != nil { + t.Fatalf("GetCredentials() error = %v", err) + } + if updated[0].Name != "My Phone" { + t.Errorf("expected updated name 'My Phone', got %q", updated[0].Name) + } + + if err := passkeys.DeleteCredential(ctx, userID, credentialIDB64); err != nil { + t.Fatalf("DeleteCredential() error = %v", err) + } + remaining, err := passkeys.GetCredentials(ctx, userID) + if err != nil { + t.Fatalf("GetCredentials() error = %v", err) + } + if len(remaining) != 0 { + t.Errorf("expected 0 credentials after delete, got %d", len(remaining)) + } +} + +func TestDirectMode_OAuthGetOrCreateUserAndSession(t *testing.T) { + db := newDirectTestDB(t) + auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect}) + ctx := context.Background() + + userCtx := &UserContext{UserName: "gina", Email: "gina@example.com", Roles: []string{"user"}} + userID, err := auth.oauth2GetOrCreateUser(ctx, userCtx, "google") + if err != nil { + t.Fatalf("oauth2GetOrCreateUser() error = %v", err) + } + if userID == 0 { + t.Fatal("expected non-zero user ID") + } + + // Calling again with the same email should return the same user, not create a duplicate. + userID2, err := auth.oauth2GetOrCreateUser(ctx, userCtx, "google") + if err != nil { + t.Fatalf("oauth2GetOrCreateUser() second call error = %v", err) + } + if userID2 != userID { + t.Errorf("expected same user ID on repeat call, got %d and %d", userID, userID2) + } +} + +func TestDirectMode_KeyStoreCreateAndValidate(t *testing.T) { + db := newDirectTestDB(t) + auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect}) + ctx := context.Background() + + regResp, err := auth.Register(ctx, RegisterRequest{Username: "henry", Password: "p", Email: "henry@example.com"}) + if err != nil { + t.Fatalf("Register() error = %v", err) + } + + ks := NewDatabaseKeyStore(db, DatabaseKeyStoreOptions{QueryMode: ModeDirect}) + + createResp, err := ks.CreateKey(ctx, CreateKeyRequest{ + UserID: regResp.User.UserID, + KeyType: KeyTypeGenericAPI, + Name: "test key", + Scopes: []string{"read", "write"}, + Meta: map[string]any{"note": "test"}, + }) + if err != nil { + t.Fatalf("CreateKey() error = %v", err) + } + if createResp.RawKey == "" { + t.Fatal("expected a non-empty raw key") + } + + validated, err := ks.ValidateKey(ctx, createResp.RawKey, KeyTypeGenericAPI) + if err != nil { + t.Fatalf("ValidateKey() error = %v", err) + } + if validated.UserID != regResp.User.UserID { + t.Errorf("ValidateKey() UserID = %d, want %d", validated.UserID, regResp.User.UserID) + } + if len(validated.Scopes) != 2 { + t.Errorf("expected 2 scopes, got %v", validated.Scopes) + } + + keys, err := ks.GetUserKeys(ctx, regResp.User.UserID, "") + if err != nil { + t.Fatalf("GetUserKeys() error = %v", err) + } + if len(keys) != 1 { + t.Fatalf("expected 1 key, got %d", len(keys)) + } + + if err := ks.DeleteKey(ctx, regResp.User.UserID, keys[0].ID); err != nil { + t.Fatalf("DeleteKey() error = %v", err) + } + + remaining, err := ks.GetUserKeys(ctx, regResp.User.UserID, "") + if err != nil { + t.Fatalf("GetUserKeys() error = %v", err) + } + if len(remaining) != 0 { + t.Errorf("expected 0 keys after delete, got %d", len(remaining)) + } +} + +func TestDirectMode_OAuthServerClientAndCode(t *testing.T) { + db := newDirectTestDB(t) + auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect}) + ctx := context.Background() + + client := &OAuthServerClient{ + ClientID: "client-1", + RedirectURIs: []string{"https://app.example.com/callback"}, + ClientName: "Example App", + } + registered, err := auth.OAuthRegisterClient(ctx, client) + if err != nil { + t.Fatalf("OAuthRegisterClient() error = %v", err) + } + if len(registered.GrantTypes) != 1 || registered.GrantTypes[0] != "authorization_code" { + t.Errorf("expected default grant types, got %v", registered.GrantTypes) + } + if len(registered.AllowedScopes) != 3 { + t.Errorf("expected default allowed scopes, got %v", registered.AllowedScopes) + } + + fetched, err := auth.OAuthGetClient(ctx, "client-1") + if err != nil { + t.Fatalf("OAuthGetClient() error = %v", err) + } + if fetched.ClientName != "Example App" { + t.Errorf("OAuthGetClient() ClientName = %q, want %q", fetched.ClientName, "Example App") + } + if len(fetched.RedirectURIs) != 1 || fetched.RedirectURIs[0] != "https://app.example.com/callback" { + t.Errorf("OAuthGetClient() RedirectURIs = %v", fetched.RedirectURIs) + } + + regResp, err := auth.Register(ctx, RegisterRequest{Username: "ivan", Password: "p", Email: "ivan@example.com"}) + if err != nil { + t.Fatalf("Register() error = %v", err) + } + + if err := auth.OAuthSaveCode(ctx, &OAuthCode{ + Code: "auth-code-2", + ClientID: "client-1", + RedirectURI: "https://app.example.com/callback", + CodeChallenge: "challenge", + SessionToken: regResp.Token, + Scopes: []string{"openid", "profile"}, + ExpiresAt: futureTime(), + }); err != nil { + t.Fatalf("OAuthSaveCode() error = %v", err) + } + + exchanged, err := auth.OAuthExchangeCode(ctx, "auth-code-2") + if err != nil { + t.Fatalf("OAuthExchangeCode() error = %v", err) + } + if exchanged.ClientID != "client-1" { + t.Errorf("OAuthExchangeCode() ClientID = %q, want client-1", exchanged.ClientID) + } + if len(exchanged.Scopes) != 2 { + t.Errorf("expected 2 scopes, got %v", exchanged.Scopes) + } + + // Code should be single-use. + if _, err := auth.OAuthExchangeCode(ctx, "auth-code-2"); err == nil { + t.Error("expected re-exchanging a used code to fail") + } + + info, err := auth.OAuthIntrospectToken(ctx, regResp.Token) + if err != nil { + t.Fatalf("OAuthIntrospectToken() error = %v", err) + } + if !info.Active { + t.Error("expected token to be active") + } + if info.Username != "ivan" { + t.Errorf("OAuthIntrospectToken() Username = %q, want ivan", info.Username) + } + + if err := auth.OAuthRevokeToken(ctx, regResp.Token); err != nil { + t.Fatalf("OAuthRevokeToken() error = %v", err) + } + + info, err = auth.OAuthIntrospectToken(ctx, regResp.Token) + if err != nil { + t.Fatalf("OAuthIntrospectToken() after revoke error = %v", err) + } + if info.Active { + t.Error("expected token to be inactive after revoke") + } +} diff --git a/pkg/security/keystore_database.go b/pkg/security/keystore_database.go index 75e7eb1..70d250a 100644 --- a/pkg/security/keystore_database.go +++ b/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/pkg/security/keystore_database_direct.go b/pkg/security/keystore_database_direct.go new file mode 100644 index 0000000..af444e7 --- /dev/null +++ b/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/pkg/security/keystore_table_names.go b/pkg/security/keystore_table_names.go new file mode 100644 index 0000000..2ecc761 --- /dev/null +++ b/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/pkg/security/oauth2_methods.go b/pkg/security/oauth2_methods.go index 79a58de..2ca859f 100644 --- a/pkg/security/oauth2_methods.go +++ b/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/pkg/security/oauth2_methods_direct.go b/pkg/security/oauth2_methods_direct.go new file mode 100644 index 0000000..abfc448 --- /dev/null +++ b/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/pkg/security/oauth_server_db.go b/pkg/security/oauth_server_db.go index 4f9a095..d0e208d 100644 --- a/pkg/security/oauth_server_db.go +++ b/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/pkg/security/oauth_server_db_direct.go b/pkg/security/oauth_server_db_direct.go new file mode 100644 index 0000000..e0e162e --- /dev/null +++ b/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/pkg/security/passkey_provider.go b/pkg/security/passkey_provider.go index fb153c1..8b9bf05 100644 --- a/pkg/security/passkey_provider.go +++ b/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,39 @@ 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 - - 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") - } - - // 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) + + if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyGetCredsByUsername) { + _, directCreds, err := p.getCredsByUsernameDirect(ctx, username) + if err != nil { + return nil, err + } + creds = directCreds + } else { + var success bool + var errorMsg sql.NullString + var userID sql.NullInt64 + var credentialsJSON sql.NullString + + 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 +328,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 +405,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 +489,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 +519,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/pkg/security/passkey_provider_direct.go b/pkg/security/passkey_provider_direct.go new file mode 100644 index 0000000..f2b1dd0 --- /dev/null +++ b/pkg/security/passkey_provider_direct.go @@ -0,0 +1,261 @@ +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 + }) +} + +func (p *DatabasePasskeyProvider) getCredsByUsernameDirect(ctx context.Context, username string) (int, []struct { + ID string `json:"credential_id"` + Transports []string `json:"transports"` +}, error) { + type credT = struct { + ID string `json:"credential_id"` + Transports []string `json:"transports"` + } + var userID int + var creds []credT + + 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([]credT, 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, credT{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/pkg/security/providers.go b/pkg/security/providers.go index 385d16d..c91bf28 100644 --- a/pkg/security/providers.go +++ b/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,10 +901,17 @@ 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) { + if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.RowSecurity) { + return RowSecurity{}, ErrDirectModeUnsupported + } + var template string var hasBlock bool @@ -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/pkg/security/providers_direct.go b/pkg/security/providers_direct.go new file mode 100644 index 0000000..c884fb7 --- /dev/null +++ b/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/pkg/security/query_mode.go b/pkg/security/query_mode.go new file mode 100644 index 0000000..9c040d1 --- /dev/null +++ b/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/pkg/security/query_mode_test.go b/pkg/security/query_mode_test.go new file mode 100644 index 0000000..fdfe649 --- /dev/null +++ b/pkg/security/query_mode_test.go @@ -0,0 +1,165 @@ +package security + +import ( + "context" + "database/sql" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + _ "github.com/mattn/go-sqlite3" +) + +func openTestSQLite(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("failed to open sqlite db: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func TestShouldUseProcedure_ModeProcedure_AlwaysTrue(t *testing.T) { + db := openTestSQLite(t) + c := newDBCapability() + if !c.ShouldUseProcedure(context.Background(), ModeProcedure, db, "resolvespec_login") { + t.Error("ModeProcedure should always resolve to true") + } +} + +func TestShouldUseProcedure_ModeDirect_AlwaysFalse(t *testing.T) { + db := openTestSQLite(t) + c := newDBCapability() + if c.ShouldUseProcedure(context.Background(), ModeDirect, db, "resolvespec_login") { + t.Error("ModeDirect should always resolve to false") + } +} + +func TestShouldUseProcedure_Auto_SQLite_ResolvesDirect(t *testing.T) { + db := openTestSQLite(t) + c := newDBCapability() + if c.ShouldUseProcedure(context.Background(), ModeAuto, db, "resolvespec_login") { + t.Error("ModeAuto against a SQLite connection should resolve to Direct (false)") + } +} + +func TestShouldUseProcedure_Auto_UnknownDriver_DefaultsToProcedure(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create mock db: %v", err) + } + defer db.Close() + + c := newDBCapability() + if !c.ShouldUseProcedure(context.Background(), ModeAuto, db, "resolvespec_login") { + t.Error("ModeAuto against an unrecognized driver (e.g. a test double) should default to Procedure (true) to preserve existing behavior") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %v", err) + } +} + +func TestShouldUseProcedure_CachesResult(t *testing.T) { + db := openTestSQLite(t) + c := newDBCapability() + first := c.ShouldUseProcedure(context.Background(), ModeAuto, db, "resolvespec_login") + if _, ok := c.funcExists.Load("resolvespec_login"); !ok { + t.Error("expected result to be cached") + } + second := c.ShouldUseProcedure(context.Background(), ModeAuto, db, "resolvespec_login") + if first != second { + t.Errorf("cached result changed: first=%v second=%v", first, second) + } +} + +func TestDBCapability_Reset_ClearsCache(t *testing.T) { + c := newDBCapability() + c.funcExists.Store("resolvespec_login", true) + c.reset() + if _, ok := c.funcExists.Load("resolvespec_login"); ok { + t.Error("reset should clear all cached entries") + } +} + +func TestProbeFunctionExists_Postgres_FunctionExists(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create mock db: %v", err) + } + defer db.Close() + + rows := sqlmock.NewRows([]string{"exists"}).AddRow(true) + mock.ExpectQuery(`SELECT EXISTS \(SELECT 1 FROM pg_proc WHERE proname = \$1 LIMIT 1\)`). + WithArgs("resolvespec_login"). + WillReturnRows(rows) + + if !probeFunctionExists(context.Background(), db, "resolvespec_login") { + t.Error("expected probeFunctionExists to return true") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %v", err) + } +} + +func TestProbeFunctionExists_Postgres_FunctionMissing(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create mock db: %v", err) + } + defer db.Close() + + rows := sqlmock.NewRows([]string{"exists"}).AddRow(false) + mock.ExpectQuery(`SELECT EXISTS \(SELECT 1 FROM pg_proc WHERE proname = \$1 LIMIT 1\)`). + WithArgs("resolvespec_login"). + WillReturnRows(rows) + + if probeFunctionExists(context.Background(), db, "resolvespec_login") { + t.Error("expected probeFunctionExists to return false") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %v", err) + } +} + +func TestProbeFunctionExists_QueryError_ReturnsFalse(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create mock db: %v", err) + } + defer db.Close() + + mock.ExpectQuery(`SELECT EXISTS \(SELECT 1 FROM pg_proc WHERE proname = \$1 LIMIT 1\)`). + WithArgs("resolvespec_login"). + WillReturnError(sql.ErrConnDone) + + if probeFunctionExists(context.Background(), db, "resolvespec_login") { + t.Error("expected probeFunctionExists to return false on query error") + } +} + +func TestProbeFunctionExists_NilDB(t *testing.T) { + if probeFunctionExists(context.Background(), nil, "resolvespec_login") { + t.Error("expected probeFunctionExists(nil) to return false") + } +} + +func TestRewritePlaceholders_NonPostgres_NoOp(t *testing.T) { + db := openTestSQLite(t) + q := "SELECT * FROM users WHERE id = ? AND name = ?" + if got := rewritePlaceholders(db, q); got != q { + t.Errorf("rewritePlaceholders on non-Postgres = %q, want unchanged %q", got, q) + } +} + +func TestGenerateSessionToken_Format(t *testing.T) { + token, err := generateSessionToken() + if err != nil { + t.Fatalf("generateSessionToken() error = %v", err) + } + if len(token) < len("sess_")+64+2 { + t.Errorf("generateSessionToken() = %q, unexpected length", token) + } + if token[:5] != "sess_" { + t.Errorf("generateSessionToken() = %q, want prefix 'sess_'", token) + } +} diff --git a/pkg/security/table_names.go b/pkg/security/table_names.go new file mode 100644 index 0000000..2a78d4c --- /dev/null +++ b/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/pkg/security/table_names_test.go b/pkg/security/table_names_test.go new file mode 100644 index 0000000..3990a06 --- /dev/null +++ b/pkg/security/table_names_test.go @@ -0,0 +1,134 @@ +package security + +import ( + "reflect" + "testing" +) + +func TestDefaultTableNames_AllFieldsNonEmpty(t *testing.T) { + names := DefaultTableNames() + 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 + } + if field.String() == "" { + t.Errorf("DefaultTableNames().%s is empty", typ.Field(i).Name) + } + } +} + +func TestMergeTableNames_PartialOverride(t *testing.T) { + base := DefaultTableNames() + override := &TableNames{Users: "custom_users", OAuthCodes: "custom_oauth_codes"} + + merged := MergeTableNames(base, override) + + if merged.Users != "custom_users" { + t.Errorf("MergeTableNames().Users = %q, want %q", merged.Users, "custom_users") + } + if merged.OAuthCodes != "custom_oauth_codes" { + t.Errorf("MergeTableNames().OAuthCodes = %q, want %q", merged.OAuthCodes, "custom_oauth_codes") + } + if merged.UserSessions != "user_sessions" { + t.Errorf("MergeTableNames().UserSessions = %q, want default", merged.UserSessions) + } +} + +func TestMergeTableNames_NilOverride(t *testing.T) { + base := DefaultTableNames() + merged := MergeTableNames(base, nil) + + if merged == base { + t.Error("MergeTableNames with nil override should return a copy, not the same pointer") + } + if *merged != *base { + t.Errorf("MergeTableNames(base, nil) = %+v, want %+v", merged, base) + } +} + +func TestMergeTableNames_DoesNotMutateBase(t *testing.T) { + base := DefaultTableNames() + original := base.Users + + override := &TableNames{Users: "custom_users"} + _ = MergeTableNames(base, override) + + if base.Users != original { + t.Errorf("MergeTableNames mutated base: Users = %q, want %q", base.Users, original) + } +} + +func TestValidateTableNames_Valid(t *testing.T) { + if err := ValidateTableNames(DefaultTableNames()); err != nil { + t.Errorf("ValidateTableNames(defaults) error = %v", err) + } +} + +func TestValidateTableNames_Invalid(t *testing.T) { + names := DefaultTableNames() + names.Users = "users; DROP TABLE users; --" + + if err := ValidateTableNames(names); err == nil { + t.Error("ValidateTableNames should reject names with invalid characters") + } +} + +func TestResolveTableNames_NoOverride(t *testing.T) { + names := resolveTableNames(nil) + if names.Users != "users" { + t.Errorf("resolveTableNames(nil).Users = %q, want default", names.Users) + } +} + +func TestResolveTableNames_WithOverride(t *testing.T) { + names := resolveTableNames(&TableNames{Users: "custom_users"}) + if names.Users != "custom_users" { + t.Errorf("resolveTableNames().Users = %q, want %q", names.Users, "custom_users") + } + if names.UserSessions != "user_sessions" { + t.Errorf("resolveTableNames().UserSessions = %q, want default", names.UserSessions) + } +} + +func TestDefaultKeyStoreTableNames(t *testing.T) { + names := DefaultKeyStoreTableNames() + if names.UserKeys != "user_keys" { + t.Errorf("DefaultKeyStoreTableNames().UserKeys = %q, want %q", names.UserKeys, "user_keys") + } +} + +func TestMergeKeyStoreTableNames_PartialOverride(t *testing.T) { + base := DefaultKeyStoreTableNames() + merged := MergeKeyStoreTableNames(base, &KeyStoreTableNames{UserKeys: "custom_keys"}) + if merged.UserKeys != "custom_keys" { + t.Errorf("MergeKeyStoreTableNames().UserKeys = %q, want %q", merged.UserKeys, "custom_keys") + } +} + +func TestMergeKeyStoreTableNames_NilOverride(t *testing.T) { + base := DefaultKeyStoreTableNames() + merged := MergeKeyStoreTableNames(base, nil) + if merged == base { + t.Error("MergeKeyStoreTableNames with nil override should return a copy, not the same pointer") + } + if *merged != *base { + t.Errorf("MergeKeyStoreTableNames(base, nil) = %+v, want %+v", merged, base) + } +} + +func TestValidateKeyStoreTableNames_Invalid(t *testing.T) { + names := &KeyStoreTableNames{UserKeys: "bad name!"} + if err := ValidateKeyStoreTableNames(names); err == nil { + t.Error("ValidateKeyStoreTableNames should reject names with invalid characters") + } +} + +func TestValidateKeyStoreTableNames_Valid(t *testing.T) { + if err := ValidateKeyStoreTableNames(DefaultKeyStoreTableNames()); err != nil { + t.Errorf("ValidateKeyStoreTableNames(defaults) error = %v", err) + } +} diff --git a/pkg/security/totp_provider_database.go b/pkg/security/totp_provider_database.go index 6fe8c5e..17c306d 100644 --- a/pkg/security/totp_provider_database.go +++ b/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/pkg/security/totp_provider_database_direct.go b/pkg/security/totp_provider_database_direct.go new file mode 100644 index 0000000..9055cd4 --- /dev/null +++ b/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 +}