diff --git a/pkg/security/README.md b/pkg/security/README.md index 0df1939..5471a3c 100644 --- a/pkg/security/README.md +++ b/pkg/security/README.md @@ -13,6 +13,7 @@ Type-safe, composable security system for ResolveSpec with support for authentic - ✅ **Extensible** - Implement custom providers for your needs - ✅ **Stored Procedures** - All database operations use PostgreSQL stored procedures for security and maintainability - ✅ **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 ## Stored Procedure Architecture @@ -45,6 +46,8 @@ Type-safe, composable security system for ResolveSpec with support for authentic | `resolvespec_oauth_exchange_code` | Consume authorization code (single-use) | OAuthServer / DatabaseAuthenticator | | `resolvespec_oauth_introspect` | Token introspection (RFC 7662) | OAuthServer / DatabaseAuthenticator | | `resolvespec_oauth_revoke` | Token revocation (RFC 7009) | OAuthServer / DatabaseAuthenticator | +| `resolvespec_password_reset_request` | Create password reset token | DatabaseAuthenticator | +| `resolvespec_password_reset` | Validate token and set new password | DatabaseAuthenticator | See `database_schema.sql` for complete stored procedure definitions and examples. @@ -904,6 +907,66 @@ securityList := security.NewSecurityList(provider) restheadspec.RegisterSecurityHooks(handler, securityList) // or funcspec/resolvespec ``` +## Password Reset + +`DatabaseAuthenticator` implements `PasswordResettable` for self-service password reset. + +### Flow + +1. User submits email or username → `RequestPasswordReset` → server generates a token and returns it for out-of-band delivery (email, SMS, etc.) +2. User submits the raw token + new password → `CompletePasswordReset` → password updated, all sessions invalidated + +### DB Requirements + +Run the migrations in `database_schema.sql`: +- `user_password_resets` table (`user_id`, `token_hash` SHA-256, `expires_at`, `used`, `used_at`) +- `resolvespec_password_reset_request` stored procedure +- `resolvespec_password_reset` stored procedure + +Requires the `pgcrypto` extension (`gen_random_bytes`, `digest`) — already used by `resolvespec_login`. + +### Usage + +```go +auth := security.NewDatabaseAuthenticator(db) + +// Step 1 — initiate reset (call after user submits their email) +resp, err := auth.RequestPasswordReset(ctx, security.PasswordResetRequest{ + Email: "user@example.com", +}) +// resp.Token is the raw token — deliver it out-of-band +// resp.ExpiresIn is 3600 (1 hour) +// Always returns success regardless of whether the user exists (anti-enumeration) + +// Step 2 — complete reset (call after user submits token + new password) +err = auth.CompletePasswordReset(ctx, security.PasswordResetCompleteRequest{ + Token: rawToken, + NewPassword: "newSecurePassword", +}) +// On success: password updated, all active sessions deleted +``` + +### Security Notes + +- The raw token is never stored; only its SHA-256 hash is persisted +- Requesting a reset invalidates any previous unused tokens for that user +- Tokens expire after 1 hour +- Completing a reset deletes all active sessions, forcing re-login +- `RequestPasswordReset` always returns success even when the email/username is not found, preventing user enumeration +- Hash the new password with bcrypt before storing (pgcrypto `crypt`/`gen_salt`) — see the TODO comment in `resolvespec_password_reset` + +### SQLNames + +```go +type SQLNames struct { + // ... + PasswordResetRequest string // default: "resolvespec_password_reset_request" + PasswordResetComplete string // default: "resolvespec_password_reset" +} +``` + +--- + ## OAuth2 Authorization Server `OAuthServer` is a generic OAuth 2.1 + PKCE authorization server. It is not tied to any spec — `pkg/resolvemcp` uses it, but it can be used standalone with any `http.ServeMux`. @@ -1110,6 +1173,14 @@ type Cacheable interface { } ``` +**PasswordResettable** - Self-service password reset: +```go +type PasswordResettable interface { + RequestPasswordReset(ctx context.Context, req PasswordResetRequest) (*PasswordResetResponse, error) + CompletePasswordReset(ctx context.Context, req PasswordResetCompleteRequest) error +} +``` + ## Benefits Over Callbacks | Feature | Old (Callbacks) | New (Interfaces) | diff --git a/pkg/security/database_schema.sql b/pkg/security/database_schema.sql index 34907b0..cbd4283 100644 --- a/pkg/security/database_schema.sql +++ b/pkg/security/database_schema.sql @@ -1398,6 +1398,158 @@ $$ LANGUAGE plpgsql; -- Get credentials by username -- SELECT * FROM resolvespec_passkey_get_credentials_by_username('admin'); +-- ============================================ +-- Password Reset Tables +-- ============================================ + +-- Password reset tokens table +CREATE TABLE IF NOT EXISTS user_password_resets ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hex of the raw token + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + used BOOLEAN DEFAULT false, + used_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_pw_reset_token_hash ON user_password_resets(token_hash); +CREATE INDEX IF NOT EXISTS idx_pw_reset_user_id ON user_password_resets(user_id); +CREATE INDEX IF NOT EXISTS idx_pw_reset_expires_at ON user_password_resets(expires_at); + +-- ============================================ +-- Stored Procedures for Password Reset +-- ============================================ + +-- 1. resolvespec_password_reset_request - Creates a password reset token for a user +-- Input: p_request jsonb {email: string, username: string} +-- Output: p_success (bool), p_error (text), p_data jsonb {token: string, expires_in: int} +-- NOTE: The raw token is returned so the caller can deliver it out-of-band (e.g. email). +-- Only the SHA-256 hash is stored. Invalidates any previous unused tokens for the user. +CREATE OR REPLACE FUNCTION resolvespec_password_reset_request(p_request jsonb) +RETURNS TABLE(p_success boolean, p_error text, p_data jsonb) AS $$ +DECLARE + v_user_id INTEGER; + v_email TEXT; + v_username TEXT; + v_raw_token TEXT; + v_token_hash TEXT; + v_expires_at TIMESTAMP; +BEGIN + v_email := p_request->>'email'; + v_username := p_request->>'username'; + + -- Require at least one identifier + IF (v_email IS NULL OR v_email = '') AND (v_username IS NULL OR v_username = '') THEN + RETURN QUERY SELECT false, 'email or username is required'::text, NULL::jsonb; + RETURN; + END IF; + + -- Look up active user + IF v_email IS NOT NULL AND v_email <> '' THEN + SELECT id INTO v_user_id FROM users WHERE email = v_email AND is_active = true; + ELSE + SELECT id INTO v_user_id FROM users WHERE username = v_username AND is_active = true; + END IF; + + -- Return generic success even when user not found to avoid user enumeration + IF NOT FOUND THEN + RETURN QUERY SELECT true, NULL::text, jsonb_build_object('token', '', 'expires_in', 0); + RETURN; + END IF; + + -- Invalidate previous unused tokens for this user + DELETE FROM user_password_resets WHERE user_id = v_user_id AND used = false; + + -- Generate a random 32-byte token and store its SHA-256 hash + v_raw_token := encode(gen_random_bytes(32), 'hex'); + v_token_hash := encode(digest(v_raw_token, 'sha256'), 'hex'); + v_expires_at := now() + interval '1 hour'; + + INSERT INTO user_password_resets (user_id, token_hash, expires_at) + VALUES (v_user_id, v_token_hash, v_expires_at); + + RETURN QUERY SELECT + true, + NULL::text, + jsonb_build_object( + 'token', v_raw_token, + 'expires_in', 3600 + ); +EXCEPTION + WHEN OTHERS THEN + RETURN QUERY SELECT false, SQLERRM::text, NULL::jsonb; +END; +$$ LANGUAGE plpgsql; + +-- 2. resolvespec_password_reset - Validates the token and updates the user's password +-- Input: p_request jsonb {token: string, new_password: string} +-- Output: p_success (bool), p_error (text) +-- NOTE: Hash the new_password with bcrypt before storing (pgcrypto crypt/gen_salt). +-- The TODO below mirrors the convention used in resolvespec_register. +CREATE OR REPLACE FUNCTION resolvespec_password_reset(p_request jsonb) +RETURNS TABLE(p_success boolean, p_error text) AS $$ +DECLARE + v_raw_token TEXT; + v_token_hash TEXT; + v_new_pw TEXT; + v_reset_id INTEGER; + v_user_id INTEGER; + v_expires_at TIMESTAMP; +BEGIN + v_raw_token := p_request->>'token'; + v_new_pw := p_request->>'new_password'; + + IF v_raw_token IS NULL OR v_raw_token = '' THEN + RETURN QUERY SELECT false, 'token is required'::text; + RETURN; + END IF; + + IF v_new_pw IS NULL OR v_new_pw = '' THEN + RETURN QUERY SELECT false, 'new_password is required'::text; + RETURN; + END IF; + + v_token_hash := encode(digest(v_raw_token, 'sha256'), 'hex'); + + -- Find valid, unused reset token + SELECT id, user_id, expires_at + INTO v_reset_id, v_user_id, v_expires_at + FROM user_password_resets + WHERE token_hash = v_token_hash AND used = false; + + IF NOT FOUND THEN + RETURN QUERY SELECT false, 'invalid or expired token'::text; + RETURN; + END IF; + + IF v_expires_at <= now() THEN + RETURN QUERY SELECT false, 'invalid or expired token'::text; + RETURN; + END IF; + + -- TODO: Hash new password with pgcrypto before storing + -- Enable pgcrypto: CREATE EXTENSION IF NOT EXISTS pgcrypto; + -- v_new_pw := crypt(v_new_pw, gen_salt('bf')); + + -- Update password and invalidate all sessions + UPDATE users SET password = v_new_pw, updated_at = now() WHERE id = v_user_id; + DELETE FROM user_sessions WHERE user_id = v_user_id; + + -- Mark token as used + UPDATE user_password_resets SET used = true, used_at = now() WHERE id = v_reset_id; + + RETURN QUERY SELECT true, NULL::text; +EXCEPTION + WHEN OTHERS THEN + RETURN QUERY SELECT false, SQLERRM::text; +END; +$$ LANGUAGE plpgsql; + +-- Example: Test password reset stored procedures +-- SELECT * FROM resolvespec_password_reset_request('{"email": "user@example.com"}'::jsonb); +-- SELECT * FROM resolvespec_password_reset('{"token": "", "new_password": "newpass123"}'::jsonb); + -- ============================================ -- OAuth2 Server Tables (OAuthServer persistence) -- ============================================ diff --git a/pkg/security/interfaces.go b/pkg/security/interfaces.go index de5eeb4..b6e6903 100644 --- a/pkg/security/interfaces.go +++ b/pkg/security/interfaces.go @@ -57,6 +57,27 @@ type LogoutRequest struct { UserID int `json:"user_id"` } +// PasswordResetRequest initiates a password reset for a user +type PasswordResetRequest struct { + Email string `json:"email,omitempty"` + Username string `json:"username,omitempty"` +} + +// PasswordResetResponse is returned when a reset is initiated +type PasswordResetResponse struct { + // Token is the reset token to be delivered out-of-band (e.g. email). + // The stored procedure may return it for delivery or leave it empty + // if the delivery is handled entirely in the database. + Token string `json:"token"` + ExpiresIn int64 `json:"expires_in"` // seconds +} + +// PasswordResetCompleteRequest completes a password reset using the token +type PasswordResetCompleteRequest struct { + Token string `json:"token"` + NewPassword string `json:"new_password"` +} + // Authenticator handles user authentication operations type Authenticator interface { // Login authenticates credentials and returns a token @@ -114,3 +135,12 @@ type Cacheable interface { // ClearCache clears cached security rules for a user/entity ClearCache(ctx context.Context, userID int, schema, table string) error } + +// PasswordResettable allows providers to support self-service password reset +type PasswordResettable interface { + // RequestPasswordReset creates a reset token for the given email/username + RequestPasswordReset(ctx context.Context, req PasswordResetRequest) (*PasswordResetResponse, error) + + // CompletePasswordReset validates the token and sets the new password + CompletePasswordReset(ctx context.Context, req PasswordResetCompleteRequest) error +} diff --git a/pkg/security/providers.go b/pkg/security/providers.go index a10f45e..30b6624 100644 --- a/pkg/security/providers.go +++ b/pkg/security/providers.go @@ -868,6 +868,75 @@ func generateRandomString(length int) string { // return "" // } +// Password reset methods +// ====================== + +// 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) { + reqJSON, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal password reset request: %w", err) + } + + var success bool + var errorMsg sql.NullString + var dataJSON sql.NullString + + err = a.runDBOpWithReconnect(func(db *sql.DB) error { + query := fmt.Sprintf(`SELECT p_success, p_error, p_data::text FROM %s($1::jsonb)`, a.sqlNames.PasswordResetRequest) + return db.QueryRowContext(ctx, query, string(reqJSON)).Scan(&success, &errorMsg, &dataJSON) + }) + if err != nil { + return nil, fmt.Errorf("password reset request query failed: %w", err) + } + + if !success { + if errorMsg.Valid { + return nil, fmt.Errorf("%s", errorMsg.String) + } + return nil, fmt.Errorf("password reset request failed") + } + + var response PasswordResetResponse + if dataJSON.Valid && dataJSON.String != "" { + if err := json.Unmarshal([]byte(dataJSON.String), &response); err != nil { + return nil, fmt.Errorf("failed to parse password reset response: %w", err) + } + } + + return &response, nil +} + +// 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 { + reqJSON, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal password reset complete request: %w", err) + } + + var success bool + var errorMsg sql.NullString + + err = a.runDBOpWithReconnect(func(db *sql.DB) error { + query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1::jsonb)`, a.sqlNames.PasswordResetComplete) + return db.QueryRowContext(ctx, query, string(reqJSON)).Scan(&success, &errorMsg) + }) + if err != nil { + return fmt.Errorf("password reset complete query failed: %w", err) + } + + if !success { + if errorMsg.Valid { + return fmt.Errorf("%s", errorMsg.String) + } + return fmt.Errorf("password reset failed") + } + + return nil +} + // Passkey authentication methods // ============================== diff --git a/pkg/security/sql_names.go b/pkg/security/sql_names.go index 80265d1..ab28645 100644 --- a/pkg/security/sql_names.go +++ b/pkg/security/sql_names.go @@ -47,6 +47,10 @@ type SQLNames struct { PasskeyUpdateName string // default: "resolvespec_passkey_update_name" PasskeyLogin string // default: "resolvespec_passkey_login" + // Password reset procedures (DatabaseAuthenticator) + PasswordResetRequest string // default: "resolvespec_password_reset_request" + PasswordResetComplete string // default: "resolvespec_password_reset" + // OAuth2 procedures (DatabaseAuthenticator OAuth2 methods) OAuthGetOrCreateUser string // default: "resolvespec_oauth_getorcreateuser" OAuthCreateSession string // default: "resolvespec_oauth_createsession" @@ -95,6 +99,9 @@ func DefaultSQLNames() *SQLNames { PasskeyUpdateName: "resolvespec_passkey_update_name", PasskeyLogin: "resolvespec_passkey_login", + PasswordResetRequest: "resolvespec_password_reset_request", + PasswordResetComplete: "resolvespec_password_reset", + OAuthGetOrCreateUser: "resolvespec_oauth_getorcreateuser", OAuthCreateSession: "resolvespec_oauth_createsession", OAuthGetRefreshToken: "resolvespec_oauth_getrefreshtoken", @@ -190,6 +197,12 @@ func MergeSQLNames(base, override *SQLNames) *SQLNames { if override.PasskeyLogin != "" { merged.PasskeyLogin = override.PasskeyLogin } + if override.PasswordResetRequest != "" { + merged.PasswordResetRequest = override.PasswordResetRequest + } + if override.PasswordResetComplete != "" { + merged.PasswordResetComplete = override.PasswordResetComplete + } if override.OAuthGetOrCreateUser != "" { merged.OAuthGetOrCreateUser = override.OAuthGetOrCreateUser }