mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 04:21:58 +00:00
Verify bcrypt passwords in Direct mode and the shipped procedures, hash on register/reset, ignore client-supplied roles and level at registration and drop the password from the jwt_login payload. Legacy cleartext upgrade is opt-in. Row security templates now bind the user as a parameter, validate identifiers, attach via common.SelectQuery and fail the request if the filter cannot be attached. ApplyColumnSecurity and GetRowSecurityTemplate convert panics to errors and the hooks fail closed. Update audit status.
1812 lines
65 KiB
PL/PgSQL
1812 lines
65 KiB
PL/PgSQL
-- Database Schema for DatabaseAuthenticator
|
|
-- ============================================
|
|
|
|
-- pgcrypto provides gen_random_bytes(), crypt() and gen_salt(); it is required
|
|
-- for session token generation and for password hashing/verification below.
|
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
|
|
-- Users table
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id SERIAL PRIMARY KEY,
|
|
username VARCHAR(255) NOT NULL UNIQUE,
|
|
email VARCHAR(255) NOT NULL UNIQUE,
|
|
password VARCHAR(255), -- bcrypt hash (nullable for OAuth2 users); legacy cleartext is accepted at login (upgrade to bcrypt is opt-in)
|
|
user_level INTEGER DEFAULT 0,
|
|
roles VARCHAR(500), -- Comma-separated roles: "admin,manager,user"
|
|
is_active BOOLEAN DEFAULT true,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
last_login_at TIMESTAMP,
|
|
-- Program-level user mapping
|
|
program_user_id INTEGER DEFAULT 0,
|
|
program_user_table VARCHAR(255) DEFAULT '',
|
|
-- OAuth2 fields
|
|
remote_id VARCHAR(255), -- Provider's user ID (e.g., Google sub, GitHub id)
|
|
auth_provider VARCHAR(50), -- 'local', 'google', 'github', 'microsoft', 'facebook', etc.
|
|
-- Two-Factor Authentication fields
|
|
totp_secret VARCHAR(255), -- Base32 encoded TOTP secret (encrypted recommended)
|
|
totp_enabled BOOLEAN DEFAULT false,
|
|
totp_enabled_at TIMESTAMP
|
|
);
|
|
|
|
-- User sessions table for DatabaseAuthenticator and OAuth2Authenticator
|
|
CREATE TABLE IF NOT EXISTS user_sessions (
|
|
id SERIAL PRIMARY KEY,
|
|
session_token VARCHAR(500) NOT NULL UNIQUE,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
last_activity_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
ip_address VARCHAR(45), -- IPv4 or IPv6
|
|
user_agent TEXT,
|
|
-- OAuth2 fields (nullable for non-OAuth2 sessions)
|
|
access_token TEXT,
|
|
refresh_token TEXT,
|
|
token_type VARCHAR(50) DEFAULT 'Bearer',
|
|
auth_provider VARCHAR(50) -- 'local', 'google', 'github', 'microsoft', 'facebook', etc.
|
|
);
|
|
|
|
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);
|
|
|
|
-- Optional: Token blacklist for logout tracking (useful for JWT too)
|
|
CREATE TABLE IF NOT EXISTS token_blacklist (
|
|
id SERIAL PRIMARY KEY,
|
|
token VARCHAR(500) NOT NULL,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_token ON token_blacklist(token);
|
|
CREATE INDEX IF NOT EXISTS idx_blacklist_expires_at ON token_blacklist(expires_at);
|
|
|
|
-- Two-Factor Authentication backup codes table
|
|
CREATE TABLE IF NOT EXISTS user_totp_backup_codes (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
code_hash VARCHAR(64) NOT NULL, -- SHA-256 hash of backup code
|
|
used BOOLEAN DEFAULT false,
|
|
used_at TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_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);
|
|
|
|
-- Example: Seed admin user (password should be hashed with bcrypt)
|
|
-- INSERT INTO users (username, email, password, user_level, roles, is_active)
|
|
-- VALUES ('admin', 'admin@example.com', '$2a$10$...', 10, 'admin,user', true);
|
|
|
|
-- Cleanup expired sessions (run periodically)
|
|
-- DELETE FROM user_sessions WHERE expires_at < NOW();
|
|
|
|
-- Cleanup expired blacklisted tokens (run periodically)
|
|
-- DELETE FROM token_blacklist WHERE expires_at < NOW();
|
|
|
|
-- ============================================
|
|
-- Stored Procedures for DatabaseAuthenticator
|
|
-- ============================================
|
|
|
|
-- 1. resolvespec_login - Authenticates user and creates session
|
|
-- Input: LoginRequest as jsonb {username: string, password: string, claims: object}
|
|
-- Output: p_success (bool), p_error (text), p_data (LoginResponse as jsonb)
|
|
CREATE OR REPLACE FUNCTION resolvespec_login(p_request jsonb)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_data jsonb) AS $$
|
|
DECLARE
|
|
v_user_id INTEGER;
|
|
v_username TEXT;
|
|
v_email TEXT;
|
|
v_user_level INTEGER;
|
|
v_roles TEXT;
|
|
v_password_hash TEXT;
|
|
v_supplied_password TEXT;
|
|
v_password_ok BOOLEAN := false;
|
|
v_session_token TEXT;
|
|
v_expires_at TIMESTAMP;
|
|
v_ip_address TEXT;
|
|
v_user_agent TEXT;
|
|
v_program_user_id INTEGER;
|
|
v_program_user_table TEXT;
|
|
BEGIN
|
|
-- Extract login request fields
|
|
v_username := p_request->>'username';
|
|
v_supplied_password := p_request->>'password';
|
|
v_ip_address := p_request->'claims'->>'ip_address';
|
|
v_user_agent := p_request->'claims'->>'user_agent';
|
|
|
|
-- Validate user credentials
|
|
SELECT id, username, email, password, user_level, roles, program_user_id, program_user_table
|
|
INTO v_user_id, v_username, v_email, v_password_hash, v_user_level, v_roles, v_program_user_id, v_program_user_table
|
|
FROM users
|
|
WHERE username = v_username AND is_active = true;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN QUERY SELECT false, 'Invalid credentials'::text, NULL::jsonb;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Verify the password. bcrypt hashes are checked with crypt(); a legacy
|
|
-- cleartext value is still accepted (and only rewritten as bcrypt if the
|
|
-- upgrade is explicitly enabled).
|
|
-- bcrypt only uses the first 72 bytes, so longer input is rejected.
|
|
IF v_password_hash IS NOT NULL AND v_password_hash <> ''
|
|
AND v_supplied_password IS NOT NULL AND v_supplied_password <> ''
|
|
AND octet_length(v_supplied_password) <= 72 THEN
|
|
IF v_password_hash ~ '^\$2[aby]\$' THEN
|
|
v_password_ok := (crypt(v_supplied_password, v_password_hash) = v_password_hash);
|
|
ELSE
|
|
v_password_ok := (v_password_hash = v_supplied_password);
|
|
-- Upgrading the stored value is opt-in:
|
|
-- ALTER DATABASE <db> SET resolvespec.upgrade_password_hash = 'on';
|
|
IF v_password_ok AND COALESCE(current_setting('resolvespec.upgrade_password_hash', true), 'off') = 'on' THEN
|
|
UPDATE users SET password = crypt(v_supplied_password, gen_salt('bf')), updated_at = now()
|
|
WHERE id = v_user_id;
|
|
END IF;
|
|
END IF;
|
|
END IF;
|
|
|
|
IF NOT v_password_ok THEN
|
|
RETURN QUERY SELECT false, 'Invalid credentials'::text, NULL::jsonb;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Generate session token
|
|
v_session_token := 'sess_' || encode(gen_random_bytes(32), 'hex') || '_' || extract(epoch from now())::bigint::text;
|
|
v_expires_at := now() + interval '24 hours';
|
|
|
|
-- Create session
|
|
INSERT INTO user_sessions (session_token, user_id, expires_at, ip_address, user_agent, last_activity_at)
|
|
VALUES (v_session_token, v_user_id, v_expires_at, v_ip_address, v_user_agent, now());
|
|
|
|
-- Update last login time
|
|
UPDATE users SET last_login_at = now() WHERE id = v_user_id;
|
|
|
|
-- Return success with LoginResponse
|
|
RETURN QUERY SELECT
|
|
true,
|
|
NULL::text,
|
|
jsonb_build_object(
|
|
'token', v_session_token,
|
|
'user', jsonb_build_object(
|
|
'user_id', v_user_id,
|
|
'user_name', v_username,
|
|
'email', v_email,
|
|
'user_level', v_user_level,
|
|
'roles', string_to_array(COALESCE(v_roles, ''), ','),
|
|
'session_id', v_session_token,
|
|
'program_user_id', COALESCE(v_program_user_id, 0),
|
|
'program_user_table', COALESCE(v_program_user_table, '')
|
|
),
|
|
'expires_in', 86400 -- 24 hours in seconds
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 2. resolvespec_logout - Invalidates session
|
|
-- Input: LogoutRequest as jsonb {token: string, user_id: int}
|
|
-- Output: p_success (bool), p_error (text), p_data (jsonb)
|
|
CREATE OR REPLACE FUNCTION resolvespec_logout(p_request jsonb)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_data jsonb) AS $$
|
|
DECLARE
|
|
v_token TEXT;
|
|
v_user_id INTEGER;
|
|
v_deleted INTEGER;
|
|
BEGIN
|
|
v_token := p_request->>'token';
|
|
v_user_id := (p_request->>'user_id')::integer;
|
|
|
|
-- Remove Bearer prefix if present
|
|
v_token := regexp_replace(v_token, '^Bearer ', '', 'i');
|
|
|
|
-- Delete the session
|
|
DELETE FROM user_sessions
|
|
WHERE session_token = v_token AND user_id = v_user_id;
|
|
|
|
GET DIAGNOSTICS v_deleted = ROW_COUNT;
|
|
|
|
IF v_deleted = 0 THEN
|
|
RETURN QUERY SELECT false, 'Session not found'::text, NULL::jsonb;
|
|
ELSE
|
|
RETURN QUERY SELECT true, NULL::text, jsonb_build_object('success', true);
|
|
END IF;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 3. resolvespec_session - Validates session and returns user context
|
|
-- Input: sessionid (text), reference (text)
|
|
-- Output: p_success (bool), p_error (text), p_user (UserContext as jsonb)
|
|
CREATE OR REPLACE FUNCTION resolvespec_session(p_session_token text, p_reference text)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_user jsonb) AS $$
|
|
DECLARE
|
|
v_user_id INTEGER;
|
|
v_username TEXT;
|
|
v_email TEXT;
|
|
v_user_level INTEGER;
|
|
v_roles TEXT;
|
|
v_session_id TEXT;
|
|
v_program_user_id INTEGER;
|
|
v_program_user_table TEXT;
|
|
BEGIN
|
|
-- Query session and user data
|
|
SELECT
|
|
s.user_id, u.username, u.email, u.user_level, u.roles, s.session_token,
|
|
u.program_user_id, u.program_user_table
|
|
INTO
|
|
v_user_id, v_username, v_email, v_user_level, v_roles, v_session_id,
|
|
v_program_user_id, v_program_user_table
|
|
FROM user_sessions s
|
|
JOIN users u ON s.user_id = u.id
|
|
WHERE s.session_token = p_session_token
|
|
AND s.expires_at > now()
|
|
AND u.is_active = true;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN QUERY SELECT false, 'Invalid or expired session'::text, NULL::jsonb;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Return UserContext
|
|
RETURN QUERY SELECT
|
|
true,
|
|
NULL::text,
|
|
jsonb_build_object(
|
|
'user_id', v_user_id,
|
|
'user_name', v_username,
|
|
'email', v_email,
|
|
'user_level', v_user_level,
|
|
'session_id', v_session_id,
|
|
'roles', string_to_array(COALESCE(v_roles, ''), ','),
|
|
'program_user_id', COALESCE(v_program_user_id, 0),
|
|
'program_user_table', COALESCE(v_program_user_table, '')
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 4. resolvespec_session_update - Updates session activity timestamp
|
|
-- Input: sessionid (text), user_context (jsonb)
|
|
-- Output: p_success (bool), p_error (text), p_user (UserContext as jsonb)
|
|
CREATE OR REPLACE FUNCTION resolvespec_session_update(p_session_token text, p_user_context jsonb)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_user jsonb) AS $$
|
|
DECLARE
|
|
v_updated INTEGER;
|
|
BEGIN
|
|
-- Update last activity timestamp
|
|
UPDATE user_sessions
|
|
SET last_activity_at = now()
|
|
WHERE session_token = p_session_token AND expires_at > now();
|
|
|
|
GET DIAGNOSTICS v_updated = ROW_COUNT;
|
|
|
|
IF v_updated = 0 THEN
|
|
RETURN QUERY SELECT false, 'Session not found or expired'::text, NULL::jsonb;
|
|
ELSE
|
|
-- Return the user context as-is
|
|
RETURN QUERY SELECT true, NULL::text, p_user_context;
|
|
END IF;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 5. resolvespec_refresh_token - Generates new session from existing one
|
|
-- Input: sessionid (text), user_context (jsonb)
|
|
-- Output: p_success (bool), p_error (text), p_user (UserContext as jsonb with new session_id)
|
|
CREATE OR REPLACE FUNCTION resolvespec_refresh_token(p_old_session_token text, p_user_context jsonb)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_user jsonb) AS $$
|
|
DECLARE
|
|
v_user_id INTEGER;
|
|
v_username TEXT;
|
|
v_email TEXT;
|
|
v_user_level INTEGER;
|
|
v_roles TEXT;
|
|
v_new_session_token TEXT;
|
|
v_expires_at TIMESTAMP;
|
|
v_ip_address TEXT;
|
|
v_user_agent TEXT;
|
|
v_program_user_id INTEGER;
|
|
v_program_user_table TEXT;
|
|
BEGIN
|
|
-- Verify old session exists and is valid
|
|
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
|
|
INTO v_user_id, v_username, v_email, v_user_level, v_roles, v_ip_address, v_user_agent,
|
|
v_program_user_id, v_program_user_table
|
|
FROM user_sessions s
|
|
JOIN users u ON s.user_id = u.id
|
|
WHERE s.session_token = p_old_session_token
|
|
AND s.expires_at > now()
|
|
AND u.is_active = true;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN QUERY SELECT false, 'Invalid or expired refresh token'::text, NULL::jsonb;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Generate new session token
|
|
v_new_session_token := 'sess_' || encode(gen_random_bytes(32), 'hex') || '_' || extract(epoch from now())::bigint::text;
|
|
v_expires_at := now() + interval '24 hours';
|
|
|
|
-- Create new session
|
|
INSERT INTO user_sessions (session_token, user_id, expires_at, ip_address, user_agent, last_activity_at)
|
|
VALUES (v_new_session_token, v_user_id, v_expires_at, v_ip_address, v_user_agent, now());
|
|
|
|
-- Delete old session
|
|
DELETE FROM user_sessions WHERE session_token = p_old_session_token;
|
|
|
|
-- Return UserContext with new session_id
|
|
RETURN QUERY SELECT
|
|
true,
|
|
NULL::text,
|
|
jsonb_build_object(
|
|
'user_id', v_user_id,
|
|
'user_name', v_username,
|
|
'email', v_email,
|
|
'user_level', v_user_level,
|
|
'session_id', v_new_session_token,
|
|
'roles', string_to_array(COALESCE(v_roles, ''), ','),
|
|
'program_user_id', COALESCE(v_program_user_id, 0),
|
|
'program_user_table', COALESCE(v_program_user_table, '')
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 6. resolvespec_jwt_login - JWT-based login (queries user and returns data for JWT token generation)
|
|
-- Input: username (text), password (text)
|
|
-- Output: p_success (bool), p_error (text), p_user (user data as jsonb)
|
|
CREATE OR REPLACE FUNCTION resolvespec_jwt_login(p_username text, p_password text)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_user jsonb) AS $$
|
|
DECLARE
|
|
v_user_id INTEGER;
|
|
v_username TEXT;
|
|
v_email TEXT;
|
|
v_password TEXT;
|
|
v_password_ok BOOLEAN := false;
|
|
v_user_level INTEGER;
|
|
v_roles TEXT;
|
|
BEGIN
|
|
-- Query user data
|
|
SELECT id, username, email, password, user_level, roles
|
|
INTO v_user_id, v_username, v_email, v_password, v_user_level, v_roles
|
|
FROM users
|
|
WHERE username = p_username AND is_active = true;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN QUERY SELECT false, 'Invalid credentials'::text, NULL::jsonb;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Verify the password (bcrypt, or legacy cleartext).
|
|
IF v_password IS NOT NULL AND v_password <> ''
|
|
AND p_password IS NOT NULL AND p_password <> ''
|
|
AND octet_length(p_password) <= 72 THEN
|
|
IF v_password ~ '^\$2[aby]\$' THEN
|
|
v_password_ok := (crypt(p_password, v_password) = v_password);
|
|
ELSE
|
|
v_password_ok := (v_password = p_password);
|
|
-- Upgrading the stored value is opt-in (see resolvespec_login).
|
|
IF v_password_ok AND COALESCE(current_setting('resolvespec.upgrade_password_hash', true), 'off') = 'on' THEN
|
|
UPDATE users SET password = crypt(p_password, gen_salt('bf')), updated_at = now()
|
|
WHERE id = v_user_id;
|
|
END IF;
|
|
END IF;
|
|
END IF;
|
|
|
|
IF NOT v_password_ok THEN
|
|
RETURN QUERY SELECT false, 'Invalid credentials'::text, NULL::jsonb;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Return user data for JWT token generation
|
|
RETURN QUERY SELECT
|
|
true,
|
|
NULL::text,
|
|
jsonb_build_object(
|
|
'id', v_user_id,
|
|
'username', v_username,
|
|
'email', v_email,
|
|
'user_level', v_user_level,
|
|
'roles', v_roles
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 7. resolvespec_jwt_logout - Adds token to blacklist
|
|
-- Input: token (text), user_id (int)
|
|
-- Output: p_success (bool), p_error (text)
|
|
CREATE OR REPLACE FUNCTION resolvespec_jwt_logout(p_token text, p_user_id integer)
|
|
RETURNS TABLE(p_success boolean, p_error text) AS $$
|
|
BEGIN
|
|
-- Add token to blacklist
|
|
INSERT INTO token_blacklist (token, user_id, expires_at)
|
|
VALUES (p_token, p_user_id, now() + interval '24 hours');
|
|
|
|
RETURN QUERY SELECT true, NULL::text;
|
|
EXCEPTION
|
|
WHEN OTHERS THEN
|
|
RETURN QUERY SELECT false, SQLERRM::text;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 8. resolvespec_column_security - Loads column security rules for user
|
|
-- Input: user_id (int), schema (text), table_name (text)
|
|
-- Output: p_success (bool), p_error (text), p_rules (array of security rules as jsonb)
|
|
CREATE OR REPLACE FUNCTION resolvespec_column_security(p_user_id integer, p_schema text, p_table_name text)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_rules jsonb) AS $$
|
|
DECLARE
|
|
v_rules jsonb;
|
|
BEGIN
|
|
-- Query column security rules from core.secaccess
|
|
SELECT jsonb_agg(
|
|
jsonb_build_object(
|
|
'control', control,
|
|
'accesstype', accesstype,
|
|
'jsonvalue', jsonvalue
|
|
)
|
|
)
|
|
INTO v_rules
|
|
FROM core.secaccess
|
|
WHERE rid_hub IN (
|
|
SELECT rid_hub_parent
|
|
FROM core.hub_link
|
|
WHERE rid_hub_child = p_user_id AND parent_hubtype = 'secgroup'
|
|
)
|
|
AND control ILIKE (p_schema || '.' || p_table_name || '%');
|
|
|
|
IF v_rules IS NULL THEN
|
|
v_rules := '[]'::jsonb;
|
|
END IF;
|
|
|
|
RETURN QUERY SELECT true, NULL::text, v_rules;
|
|
EXCEPTION
|
|
WHEN OTHERS THEN
|
|
RETURN QUERY SELECT false, SQLERRM::text, '[]'::jsonb;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 9. resolvespec_row_security - Loads row security template for user (replaces core.api_sec_rowtemplate)
|
|
-- Input: schema (text), table_name (text), user_id (int)
|
|
-- Output: p_template (text), p_block (bool)
|
|
CREATE OR REPLACE FUNCTION resolvespec_row_security(p_schema text, p_table_name text, p_user_id integer)
|
|
RETURNS TABLE(p_template text, p_block boolean) AS $$
|
|
BEGIN
|
|
-- Call the existing core function if it exists, or implement your own logic
|
|
-- This is a placeholder that you should customize based on your core.api_sec_rowtemplate logic
|
|
RETURN QUERY SELECT ''::text, false;
|
|
|
|
-- Example implementation:
|
|
-- RETURN QUERY SELECT template, has_block
|
|
-- FROM core.row_security_config
|
|
-- WHERE schema_name = p_schema AND table_name = p_table_name AND user_id = p_user_id;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 10. resolvespec_register - Registers a new user and creates session
|
|
-- Input: RegisterRequest as jsonb {username: string, password: string, email: string, claims: object, meta: object}
|
|
-- (user_level / roles in the request are ignored; new users are unprivileged)
|
|
-- Output: p_success (bool), p_error (text), p_data (LoginResponse as jsonb)
|
|
CREATE OR REPLACE FUNCTION resolvespec_register(p_request jsonb)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_data jsonb) AS $$
|
|
DECLARE
|
|
v_user_id INTEGER;
|
|
v_username TEXT;
|
|
v_email TEXT;
|
|
v_password TEXT;
|
|
v_user_level INTEGER;
|
|
v_roles TEXT;
|
|
v_session_token TEXT;
|
|
v_expires_at TIMESTAMP;
|
|
v_ip_address TEXT;
|
|
v_user_agent TEXT;
|
|
v_roles_array TEXT[];
|
|
v_program_user_id INTEGER;
|
|
v_program_user_table TEXT;
|
|
BEGIN
|
|
-- Extract registration request fields
|
|
v_username := p_request->>'username';
|
|
v_email := p_request->>'email';
|
|
v_password := p_request->>'password';
|
|
-- Privileges are never taken from the request: self-registration always
|
|
-- creates an unprivileged user (level 0, no roles, no program user link).
|
|
v_user_level := 0;
|
|
v_roles := '';
|
|
v_ip_address := p_request->'claims'->>'ip_address';
|
|
v_user_agent := p_request->'claims'->>'user_agent';
|
|
v_program_user_id := 0;
|
|
v_program_user_table := '';
|
|
|
|
-- Validate required fields
|
|
IF v_username IS NULL OR v_username = '' THEN
|
|
RETURN QUERY SELECT false, 'Username is required'::text, NULL::jsonb;
|
|
RETURN;
|
|
END IF;
|
|
|
|
IF v_email IS NULL OR v_email = '' THEN
|
|
RETURN QUERY SELECT false, 'Email is required'::text, NULL::jsonb;
|
|
RETURN;
|
|
END IF;
|
|
|
|
IF v_password IS NULL OR v_password = '' THEN
|
|
RETURN QUERY SELECT false, 'Password is required'::text, NULL::jsonb;
|
|
RETURN;
|
|
END IF;
|
|
|
|
IF octet_length(v_password) > 72 THEN
|
|
RETURN QUERY SELECT false, 'Password must be at most 72 bytes'::text, NULL::jsonb;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Check if username already exists
|
|
IF EXISTS (SELECT 1 FROM users WHERE username = v_username) THEN
|
|
RETURN QUERY SELECT false, 'Username already exists'::text, NULL::jsonb;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Check if email already exists
|
|
IF EXISTS (SELECT 1 FROM users WHERE email = v_email) THEN
|
|
RETURN QUERY SELECT false, 'Email already exists'::text, NULL::jsonb;
|
|
RETURN;
|
|
END IF;
|
|
|
|
v_password := crypt(v_password, gen_salt('bf'));
|
|
|
|
-- Create new user
|
|
INSERT INTO users (username, email, password, user_level, roles, is_active, created_at, updated_at, program_user_id, program_user_table)
|
|
VALUES (v_username, v_email, v_password, v_user_level, v_roles, true, now(), now(), v_program_user_id, v_program_user_table)
|
|
RETURNING id INTO v_user_id;
|
|
|
|
-- Generate session token
|
|
v_session_token := 'sess_' || encode(gen_random_bytes(32), 'hex') || '_' || extract(epoch from now())::bigint::text;
|
|
v_expires_at := now() + interval '24 hours';
|
|
|
|
-- Create session
|
|
INSERT INTO user_sessions (session_token, user_id, expires_at, ip_address, user_agent, last_activity_at)
|
|
VALUES (v_session_token, v_user_id, v_expires_at, v_ip_address, v_user_agent, now());
|
|
|
|
-- Update last login time
|
|
UPDATE users SET last_login_at = now() WHERE id = v_user_id;
|
|
|
|
-- Return success with LoginResponse
|
|
RETURN QUERY SELECT
|
|
true,
|
|
NULL::text,
|
|
jsonb_build_object(
|
|
'token', v_session_token,
|
|
'user', jsonb_build_object(
|
|
'user_id', v_user_id,
|
|
'user_name', v_username,
|
|
'email', v_email,
|
|
'user_level', v_user_level,
|
|
'roles', string_to_array(COALESCE(v_roles, ''), ','),
|
|
'session_id', v_session_token,
|
|
'program_user_id', v_program_user_id,
|
|
'program_user_table', v_program_user_table
|
|
),
|
|
'expires_in', 86400 -- 24 hours in seconds
|
|
);
|
|
EXCEPTION
|
|
WHEN OTHERS THEN
|
|
RETURN QUERY SELECT false, SQLERRM::text, NULL::jsonb;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- ============================================
|
|
-- Example: Test stored procedures
|
|
-- ============================================
|
|
|
|
-- Test register
|
|
-- SELECT * FROM resolvespec_register('{"username": "newuser", "password": "test123", "email": "newuser@example.com", "user_level": 1, "roles": ["user"], "claims": {"ip_address": "127.0.0.1", "user_agent": "test"}}'::jsonb);
|
|
|
|
-- Test login
|
|
-- SELECT * FROM resolvespec_login('{"username": "admin", "password": "test123", "claims": {"ip_address": "127.0.0.1", "user_agent": "test"}}'::jsonb);
|
|
|
|
-- Test session validation
|
|
-- SELECT * FROM resolvespec_session('sess_abc123', 'test_reference');
|
|
|
|
-- Test session update
|
|
-- SELECT * FROM resolvespec_session_update('sess_abc123', '{"user_id": 1, "user_name": "admin"}'::jsonb);
|
|
|
|
-- Test token refresh
|
|
-- SELECT * FROM resolvespec_refresh_token('sess_abc123', '{"user_id": 1, "user_name": "admin"}'::jsonb);
|
|
|
|
-- Test logout
|
|
-- SELECT * FROM resolvespec_logout('{"token": "sess_abc123", "user_id": 1}'::jsonb);
|
|
|
|
-- Test JWT login
|
|
-- SELECT * FROM resolvespec_jwt_login('admin', 'password123');
|
|
|
|
-- Test JWT logout
|
|
-- SELECT * FROM resolvespec_jwt_logout('jwt_token_here', 1);
|
|
|
|
-- Test column security
|
|
-- SELECT * FROM resolvespec_column_security(1, 'public', 'users');
|
|
|
|
-- Test row security
|
|
-- SELECT * FROM resolvespec_row_security('public', 'users', 1);
|
|
|
|
-- ============================================
|
|
-- OAuth2 Stored Procedures
|
|
-- ============================================
|
|
|
|
-- 11. resolvespec_oauth_getorcreateuser - Gets existing user by email or creates new OAuth2 user
|
|
-- Input: p_user_data (jsonb) {username: string, email: string, remote_id: string, user_level: int, roles: array, auth_provider: string}
|
|
-- Output: p_success (bool), p_error (text), p_user_id (int)
|
|
CREATE OR REPLACE FUNCTION resolvespec_oauth_getorcreateuser(p_user_data jsonb)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_user_id integer) AS $$
|
|
DECLARE
|
|
v_user_id INTEGER;
|
|
v_username TEXT;
|
|
v_email TEXT;
|
|
v_remote_id TEXT;
|
|
v_user_level INTEGER;
|
|
v_roles TEXT;
|
|
v_auth_provider TEXT;
|
|
BEGIN
|
|
-- Extract user data
|
|
v_username := p_user_data->>'username';
|
|
v_email := p_user_data->>'email';
|
|
v_remote_id := p_user_data->>'remote_id';
|
|
v_user_level := COALESCE((p_user_data->>'user_level')::integer, 0);
|
|
v_auth_provider := COALESCE(p_user_data->>'auth_provider', 'oauth2');
|
|
|
|
-- Convert roles array to comma-separated string
|
|
SELECT array_to_string(ARRAY(SELECT jsonb_array_elements_text(p_user_data->'roles')), ',')
|
|
INTO v_roles;
|
|
|
|
-- Try to find existing user by email
|
|
SELECT id INTO v_user_id FROM users WHERE email = v_email;
|
|
|
|
IF FOUND THEN
|
|
-- Update last login and remote_id if not set
|
|
UPDATE users
|
|
SET last_login_at = now(),
|
|
updated_at = now(),
|
|
remote_id = COALESCE(remote_id, v_remote_id),
|
|
auth_provider = COALESCE(auth_provider, v_auth_provider)
|
|
WHERE id = v_user_id;
|
|
|
|
RETURN QUERY SELECT true, NULL::text, v_user_id;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Create new user (OAuth2 users don't have password)
|
|
INSERT INTO users (username, email, password, user_level, roles, is_active, created_at, updated_at, last_login_at, remote_id, auth_provider)
|
|
VALUES (v_username, v_email, NULL, v_user_level, v_roles, true, now(), now(), now(), v_remote_id, v_auth_provider)
|
|
RETURNING id INTO v_user_id;
|
|
|
|
RETURN QUERY SELECT true, NULL::text, v_user_id;
|
|
EXCEPTION
|
|
WHEN OTHERS THEN
|
|
RETURN QUERY SELECT false, SQLERRM::text, NULL::integer;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 12. resolvespec_oauth_createsession - Creates or updates OAuth2 session in user_sessions table
|
|
-- Input: p_session_data (jsonb) {session_token: string, user_id: int, access_token: string, refresh_token: string, token_type: string, expires_at: timestamp, auth_provider: string}
|
|
-- Output: p_success (bool), p_error (text)
|
|
CREATE OR REPLACE FUNCTION resolvespec_oauth_createsession(p_session_data jsonb)
|
|
RETURNS TABLE(p_success boolean, p_error text) AS $$
|
|
DECLARE
|
|
v_session_token TEXT;
|
|
v_user_id INTEGER;
|
|
v_access_token TEXT;
|
|
v_refresh_token TEXT;
|
|
v_token_type TEXT;
|
|
v_expires_at TIMESTAMP;
|
|
v_auth_provider TEXT;
|
|
BEGIN
|
|
-- Extract session data
|
|
v_session_token := p_session_data->>'session_token';
|
|
v_user_id := (p_session_data->>'user_id')::integer;
|
|
v_access_token := p_session_data->>'access_token';
|
|
v_refresh_token := p_session_data->>'refresh_token';
|
|
v_token_type := COALESCE(p_session_data->>'token_type', 'Bearer');
|
|
v_expires_at := (p_session_data->>'expires_at')::timestamp;
|
|
v_auth_provider := COALESCE(p_session_data->>'auth_provider', 'oauth2');
|
|
|
|
-- Insert or update session
|
|
INSERT INTO user_sessions (
|
|
session_token, user_id, expires_at, created_at, last_activity_at,
|
|
access_token, refresh_token, token_type, auth_provider
|
|
)
|
|
VALUES (
|
|
v_session_token, v_user_id, v_expires_at, now(), now(),
|
|
v_access_token, v_refresh_token, v_token_type, v_auth_provider
|
|
)
|
|
ON CONFLICT (session_token) DO UPDATE
|
|
SET access_token = EXCLUDED.access_token,
|
|
refresh_token = EXCLUDED.refresh_token,
|
|
token_type = EXCLUDED.token_type,
|
|
expires_at = EXCLUDED.expires_at,
|
|
last_activity_at = now();
|
|
|
|
RETURN QUERY SELECT true, NULL::text;
|
|
EXCEPTION
|
|
WHEN OTHERS THEN
|
|
RETURN QUERY SELECT false, SQLERRM::text;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 13. resolvespec_oauth_getsession - Gets OAuth2 session and user data by session token
|
|
-- Input: p_session_token (text)
|
|
-- Output: p_success (bool), p_error (text), p_data (jsonb) with user and session info
|
|
CREATE OR REPLACE FUNCTION resolvespec_oauth_getsession(p_session_token text)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_data jsonb) AS $$
|
|
DECLARE
|
|
v_user_id INTEGER;
|
|
v_username TEXT;
|
|
v_email TEXT;
|
|
v_user_level INTEGER;
|
|
v_roles TEXT;
|
|
v_expires_at TIMESTAMP;
|
|
v_program_user_id INTEGER;
|
|
v_program_user_table TEXT;
|
|
BEGIN
|
|
-- Query session and user data from user_sessions table
|
|
SELECT
|
|
s.user_id, u.username, u.email, u.user_level, u.roles, s.expires_at,
|
|
u.program_user_id, u.program_user_table
|
|
INTO
|
|
v_user_id, v_username, v_email, v_user_level, v_roles, v_expires_at,
|
|
v_program_user_id, v_program_user_table
|
|
FROM user_sessions s
|
|
JOIN users u ON s.user_id = u.id
|
|
WHERE s.session_token = p_session_token
|
|
AND s.expires_at > now()
|
|
AND u.is_active = true;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN QUERY SELECT false, 'Invalid or expired session'::text, NULL::jsonb;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Return user context
|
|
RETURN QUERY SELECT
|
|
true,
|
|
NULL::text,
|
|
jsonb_build_object(
|
|
'user_id', v_user_id,
|
|
'user_name', v_username,
|
|
'email', v_email,
|
|
'user_level', v_user_level,
|
|
'session_id', p_session_token,
|
|
'roles', string_to_array(COALESCE(v_roles, ''), ','),
|
|
'program_user_id', COALESCE(v_program_user_id, 0),
|
|
'program_user_table', COALESCE(v_program_user_table, '')
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 14. resolvespec_oauth_deletesession - Deletes OAuth2 session from user_sessions (logout)
|
|
-- Input: p_session_token (text)
|
|
-- Output: p_success (bool), p_error (text)
|
|
CREATE OR REPLACE FUNCTION resolvespec_oauth_deletesession(p_session_token text)
|
|
RETURNS TABLE(p_success boolean, p_error text) AS $$
|
|
DECLARE
|
|
v_deleted INTEGER;
|
|
BEGIN
|
|
-- Delete the session
|
|
DELETE FROM user_sessions
|
|
WHERE session_token = p_session_token;
|
|
|
|
GET DIAGNOSTICS v_deleted = ROW_COUNT;
|
|
|
|
IF v_deleted = 0 THEN
|
|
RETURN QUERY SELECT false, 'Session not found'::text;
|
|
ELSE
|
|
RETURN QUERY SELECT true, NULL::text;
|
|
END IF;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 15. resolvespec_oauth_getrefreshtoken - Gets OAuth2 session data by refresh token from user_sessions
|
|
-- Input: p_refresh_token (text)
|
|
-- Output: p_success (bool), p_error (text), p_data (jsonb) with session info
|
|
CREATE OR REPLACE FUNCTION resolvespec_oauth_getrefreshtoken(p_refresh_token text)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_data jsonb) AS $$
|
|
DECLARE
|
|
v_user_id INTEGER;
|
|
v_access_token TEXT;
|
|
v_token_type TEXT;
|
|
v_expires_at TIMESTAMP;
|
|
BEGIN
|
|
-- Query session by refresh token
|
|
SELECT
|
|
user_id, access_token, token_type, expires_at
|
|
INTO
|
|
v_user_id, v_access_token, v_token_type, v_expires_at
|
|
FROM user_sessions
|
|
WHERE refresh_token = p_refresh_token
|
|
AND expires_at > now();
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN QUERY SELECT false, 'Refresh token not found or expired'::text, NULL::jsonb;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Return session data
|
|
RETURN QUERY SELECT
|
|
true,
|
|
NULL::text,
|
|
jsonb_build_object(
|
|
'user_id', v_user_id,
|
|
'access_token', v_access_token,
|
|
'token_type', v_token_type,
|
|
'expiry', v_expires_at
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 16. resolvespec_oauth_updaterefreshtoken - Updates OAuth2 session with new tokens in user_sessions
|
|
-- Input: p_update_data (jsonb) {user_id: int, old_refresh_token: string, new_session_token: string, new_access_token: string, new_refresh_token: string, expires_at: timestamp}
|
|
-- Output: p_success (bool), p_error (text)
|
|
CREATE OR REPLACE FUNCTION resolvespec_oauth_updaterefreshtoken(p_update_data jsonb)
|
|
RETURNS TABLE(p_success boolean, p_error text) AS $$
|
|
DECLARE
|
|
v_user_id INTEGER;
|
|
v_old_refresh_token TEXT;
|
|
v_new_session_token TEXT;
|
|
v_new_access_token TEXT;
|
|
v_new_refresh_token TEXT;
|
|
v_expires_at TIMESTAMP;
|
|
v_updated INTEGER;
|
|
BEGIN
|
|
-- Extract update data
|
|
v_user_id := (p_update_data->>'user_id')::integer;
|
|
v_old_refresh_token := p_update_data->>'old_refresh_token';
|
|
v_new_session_token := p_update_data->>'new_session_token';
|
|
v_new_access_token := p_update_data->>'new_access_token';
|
|
v_new_refresh_token := p_update_data->>'new_refresh_token';
|
|
v_expires_at := (p_update_data->>'expires_at')::timestamp;
|
|
|
|
-- Update session in user_sessions table
|
|
UPDATE user_sessions
|
|
SET session_token = v_new_session_token,
|
|
access_token = v_new_access_token,
|
|
refresh_token = v_new_refresh_token,
|
|
expires_at = v_expires_at,
|
|
last_activity_at = now()
|
|
WHERE user_id = v_user_id
|
|
AND refresh_token = v_old_refresh_token;
|
|
|
|
GET DIAGNOSTICS v_updated = ROW_COUNT;
|
|
|
|
IF v_updated = 0 THEN
|
|
RETURN QUERY SELECT false, 'Session not found'::text;
|
|
ELSE
|
|
RETURN QUERY SELECT true, NULL::text;
|
|
END IF;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 17. resolvespec_oauth_getuser - Gets user data by user ID for OAuth2 token refresh
|
|
-- Input: p_user_id (int)
|
|
-- Output: p_success (bool), p_error (text), p_data (jsonb) with user info
|
|
CREATE OR REPLACE FUNCTION resolvespec_oauth_getuser(p_user_id integer)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_data jsonb) AS $$
|
|
DECLARE
|
|
v_username TEXT;
|
|
v_email TEXT;
|
|
v_user_level INTEGER;
|
|
v_roles TEXT;
|
|
v_program_user_id INTEGER;
|
|
v_program_user_table TEXT;
|
|
BEGIN
|
|
-- Query user data
|
|
SELECT username, email, user_level, roles, program_user_id, program_user_table
|
|
INTO v_username, v_email, v_user_level, v_roles, v_program_user_id, v_program_user_table
|
|
FROM users
|
|
WHERE id = p_user_id
|
|
AND is_active = true;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN QUERY SELECT false, 'User not found'::text, NULL::jsonb;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Return user data
|
|
RETURN QUERY SELECT
|
|
true,
|
|
NULL::text,
|
|
jsonb_build_object(
|
|
'user_id', p_user_id,
|
|
'user_name', v_username,
|
|
'email', v_email,
|
|
'user_level', v_user_level,
|
|
'roles', string_to_array(COALESCE(v_roles, ''), ','),
|
|
'program_user_id', COALESCE(v_program_user_id, 0),
|
|
'program_user_table', COALESCE(v_program_user_table, '')
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- ============================================
|
|
-- Example: Test OAuth2 stored procedures
|
|
-- ============================================
|
|
|
|
-- Test get or create user
|
|
-- SELECT * FROM resolvespec_oauth_getorcreateuser('{"username": "johndoe", "email": "john@example.com", "remote_id": "google-123", "user_level": 1, "roles": ["user"], "auth_provider": "google"}'::jsonb);
|
|
|
|
-- Test create session
|
|
-- SELECT * FROM resolvespec_oauth_createsession('{"session_token": "sess_abc123", "user_id": 1, "access_token": "access_token_xyz", "refresh_token": "refresh_token_xyz", "token_type": "Bearer", "expires_at": "2026-02-01 00:00:00", "auth_provider": "google"}'::jsonb);
|
|
|
|
-- Test get session
|
|
-- SELECT * FROM resolvespec_oauth_getsession('sess_abc123');
|
|
|
|
-- Test delete session
|
|
-- SELECT * FROM resolvespec_oauth_deletesession('sess_abc123');
|
|
|
|
-- Test get refresh token
|
|
-- SELECT * FROM resolvespec_oauth_getrefreshtoken('refresh_token_xyz');
|
|
|
|
-- Test update refresh token
|
|
-- SELECT * FROM resolvespec_oauth_updaterefreshtoken('{"user_id": 1, "old_refresh_token": "refresh_token_xyz", "new_session_token": "sess_new123", "new_access_token": "new_access_token", "new_refresh_token": "new_refresh_token", "expires_at": "2026-02-02 00:00:00"}'::jsonb);
|
|
|
|
-- Test get user
|
|
-- SELECT * FROM resolvespec_oauth_getuser(1);
|
|
|
|
|
|
-- ============================================
|
|
-- Stored Procedures for Two-Factor Authentication
|
|
-- ============================================
|
|
|
|
-- 1. resolvespec_totp_enable - Enable 2FA for a user
|
|
-- Input: p_user_id (integer), p_secret (text), p_backup_codes (jsonb array)
|
|
-- Output: p_success (bool), p_error (text)
|
|
CREATE OR REPLACE FUNCTION resolvespec_totp_enable(
|
|
p_user_id INTEGER,
|
|
p_secret TEXT,
|
|
p_backup_codes jsonb
|
|
)
|
|
RETURNS TABLE(p_success boolean, p_error text) AS $$
|
|
DECLARE
|
|
v_code TEXT;
|
|
v_code_hash TEXT;
|
|
BEGIN
|
|
-- Update user record with TOTP secret
|
|
UPDATE users
|
|
SET totp_secret = p_secret,
|
|
totp_enabled = true,
|
|
totp_enabled_at = CURRENT_TIMESTAMP
|
|
WHERE id = p_user_id;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN QUERY SELECT false, 'User not found'::text;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Delete old backup codes
|
|
DELETE FROM user_totp_backup_codes WHERE user_id = p_user_id;
|
|
|
|
-- Insert new backup codes
|
|
FOR i IN 0..jsonb_array_length(p_backup_codes)-1 LOOP
|
|
v_code_hash := p_backup_codes->>i;
|
|
INSERT INTO user_totp_backup_codes (user_id, code_hash)
|
|
VALUES (p_user_id, v_code_hash);
|
|
END LOOP;
|
|
|
|
RETURN QUERY SELECT true, NULL::text;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 2. resolvespec_totp_disable - Disable 2FA for a user
|
|
-- Input: p_user_id (integer)
|
|
-- Output: p_success (bool), p_error (text)
|
|
CREATE OR REPLACE FUNCTION resolvespec_totp_disable(p_user_id INTEGER)
|
|
RETURNS TABLE(p_success boolean, p_error text) AS $$
|
|
BEGIN
|
|
-- Clear TOTP secret and disable 2FA
|
|
UPDATE users
|
|
SET totp_secret = NULL,
|
|
totp_enabled = false
|
|
WHERE id = p_user_id;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN QUERY SELECT false, 'User not found'::text;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Delete all backup codes
|
|
DELETE FROM user_totp_backup_codes WHERE user_id = p_user_id;
|
|
|
|
RETURN QUERY SELECT true, NULL::text;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 3. resolvespec_totp_get_status - Check if user has 2FA enabled
|
|
-- Input: p_user_id (integer)
|
|
-- Output: p_success (bool), p_error (text), p_enabled (bool)
|
|
CREATE OR REPLACE FUNCTION resolvespec_totp_get_status(p_user_id INTEGER)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_enabled boolean) AS $$
|
|
DECLARE
|
|
v_enabled BOOLEAN;
|
|
BEGIN
|
|
SELECT totp_enabled
|
|
INTO v_enabled
|
|
FROM users
|
|
WHERE id = p_user_id;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN QUERY SELECT false, 'User not found'::text, false;
|
|
RETURN;
|
|
END IF;
|
|
|
|
RETURN QUERY SELECT true, NULL::text, COALESCE(v_enabled, false);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 4. resolvespec_totp_get_secret - Get user's TOTP secret
|
|
-- Input: p_user_id (integer)
|
|
-- Output: p_success (bool), p_error (text), p_secret (text)
|
|
CREATE OR REPLACE FUNCTION resolvespec_totp_get_secret(p_user_id INTEGER)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_secret text) AS $$
|
|
DECLARE
|
|
v_secret TEXT;
|
|
v_enabled BOOLEAN;
|
|
BEGIN
|
|
SELECT totp_secret, totp_enabled
|
|
INTO v_secret, v_enabled
|
|
FROM users
|
|
WHERE id = p_user_id;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN QUERY SELECT false, 'User not found'::text, NULL::text;
|
|
RETURN;
|
|
END IF;
|
|
|
|
IF NOT COALESCE(v_enabled, false) THEN
|
|
RETURN QUERY SELECT false, 'TOTP not enabled for user'::text, NULL::text;
|
|
RETURN;
|
|
END IF;
|
|
|
|
RETURN QUERY SELECT true, NULL::text, v_secret;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 5. resolvespec_totp_regenerate_backup_codes - Generate new backup codes
|
|
-- Input: p_user_id (integer), p_backup_codes (jsonb array of hashed codes)
|
|
-- Output: p_success (bool), p_error (text)
|
|
CREATE OR REPLACE FUNCTION resolvespec_totp_regenerate_backup_codes(
|
|
p_user_id INTEGER,
|
|
p_backup_codes jsonb
|
|
)
|
|
RETURNS TABLE(p_success boolean, p_error text) AS $$
|
|
DECLARE
|
|
v_code_hash TEXT;
|
|
BEGIN
|
|
-- Verify user exists and has 2FA enabled
|
|
IF NOT EXISTS (SELECT 1 FROM users WHERE id = p_user_id AND totp_enabled = true) THEN
|
|
RETURN QUERY SELECT false, 'User not found or TOTP not enabled'::text;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Delete old backup codes
|
|
DELETE FROM user_totp_backup_codes WHERE user_id = p_user_id;
|
|
|
|
-- Insert new backup codes
|
|
FOR i IN 0..jsonb_array_length(p_backup_codes)-1 LOOP
|
|
v_code_hash := p_backup_codes->>i;
|
|
INSERT INTO user_totp_backup_codes (user_id, code_hash)
|
|
VALUES (p_user_id, v_code_hash);
|
|
END LOOP;
|
|
|
|
RETURN QUERY SELECT true, NULL::text;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 6. resolvespec_totp_validate_backup_code - Validate and mark backup code as used
|
|
-- Input: p_user_id (integer), p_code_hash (text)
|
|
-- Output: p_success (bool), p_error (text), p_valid (bool)
|
|
CREATE OR REPLACE FUNCTION resolvespec_totp_validate_backup_code(
|
|
p_user_id INTEGER,
|
|
p_code_hash TEXT
|
|
)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_valid boolean) AS $$
|
|
DECLARE
|
|
v_code_id INTEGER;
|
|
v_used BOOLEAN;
|
|
BEGIN
|
|
-- Find the backup code
|
|
SELECT id, used
|
|
INTO v_code_id, v_used
|
|
FROM user_totp_backup_codes
|
|
WHERE user_id = p_user_id AND code_hash = p_code_hash;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN QUERY SELECT true, NULL::text, false;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Check if already used
|
|
IF v_used THEN
|
|
RETURN QUERY SELECT false, 'Backup code already used'::text, false;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Mark as used
|
|
UPDATE user_totp_backup_codes
|
|
SET used = true, used_at = CURRENT_TIMESTAMP
|
|
WHERE id = v_code_id;
|
|
|
|
RETURN QUERY SELECT true, NULL::text, true;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- ============================================
|
|
-- Example: Test TOTP stored procedures
|
|
-- ============================================
|
|
|
|
-- Enable 2FA
|
|
-- SELECT * FROM resolvespec_totp_enable(1, 'JBSWY3DPEHPK3PXP', '["abc123", "def456"]'::jsonb);
|
|
|
|
-- Disable 2FA
|
|
-- SELECT * FROM resolvespec_totp_disable(1);
|
|
|
|
-- Get 2FA status
|
|
-- SELECT * FROM resolvespec_totp_get_status(1);
|
|
|
|
-- Get TOTP secret
|
|
-- SELECT * FROM resolvespec_totp_get_secret(1);
|
|
|
|
-- Regenerate backup codes
|
|
-- SELECT * FROM resolvespec_totp_regenerate_backup_codes(1, '["new123", "new456"]'::jsonb);
|
|
|
|
-- Validate backup code
|
|
-- SELECT * FROM resolvespec_totp_validate_backup_code(1, 'abc123');
|
|
|
|
-- ============================================
|
|
-- Passkey/WebAuthn Credentials Table
|
|
-- ============================================
|
|
|
|
-- Passkey credentials table for WebAuthn/FIDO2 authentication
|
|
CREATE TABLE IF NOT EXISTS user_passkey_credentials (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
credential_id BYTEA NOT NULL UNIQUE, -- Raw credential ID from authenticator
|
|
public_key BYTEA NOT NULL, -- COSE public key
|
|
attestation_type VARCHAR(50) DEFAULT 'none', -- none, indirect, direct
|
|
aaguid BYTEA, -- Authenticator AAGUID
|
|
sign_count INTEGER DEFAULT 0, -- Signature counter for clone detection
|
|
clone_warning BOOLEAN DEFAULT false, -- True if cloning detected
|
|
transports TEXT[], -- Array of transports: usb, nfc, ble, internal
|
|
backup_eligible BOOLEAN DEFAULT false, -- Credential can be backed up
|
|
backup_state BOOLEAN DEFAULT false, -- Credential is currently backed up
|
|
name VARCHAR(255), -- User-friendly name for the credential
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
last_used_at TIMESTAMP DEFAULT CURRENT_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);
|
|
|
|
-- ============================================
|
|
-- Stored Procedures for Passkey Authentication
|
|
-- ============================================
|
|
|
|
-- 1. resolvespec_passkey_store_credential - Store a new passkey credential
|
|
-- Input: p_credential (jsonb) {user_id: int, credential_id: bytea, public_key: bytea, attestation_type: string, aaguid: bytea, sign_count: int, transports: array, backup_eligible: bool, backup_state: bool, name: string}
|
|
-- Output: p_success (bool), p_error (text), p_credential_id (int)
|
|
CREATE OR REPLACE FUNCTION resolvespec_passkey_store_credential(p_credential jsonb)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_credential_id integer) AS $$
|
|
DECLARE
|
|
v_credential_id INTEGER;
|
|
v_user_id INTEGER;
|
|
v_cred_id BYTEA;
|
|
v_public_key BYTEA;
|
|
v_attestation_type TEXT;
|
|
v_aaguid BYTEA;
|
|
v_sign_count INTEGER;
|
|
v_transports TEXT[];
|
|
v_backup_eligible BOOLEAN;
|
|
v_backup_state BOOLEAN;
|
|
v_name TEXT;
|
|
BEGIN
|
|
-- Extract credential data
|
|
v_user_id := (p_credential->>'user_id')::integer;
|
|
v_cred_id := decode(p_credential->>'credential_id', 'base64');
|
|
v_public_key := decode(p_credential->>'public_key', 'base64');
|
|
v_attestation_type := COALESCE(p_credential->>'attestation_type', 'none');
|
|
v_aaguid := decode(COALESCE(p_credential->>'aaguid', ''), 'base64');
|
|
v_sign_count := COALESCE((p_credential->>'sign_count')::integer, 0);
|
|
v_backup_eligible := COALESCE((p_credential->>'backup_eligible')::boolean, false);
|
|
v_backup_state := COALESCE((p_credential->>'backup_state')::boolean, false);
|
|
v_name := p_credential->>'name';
|
|
|
|
-- Convert transports array
|
|
IF p_credential->'transports' IS NOT NULL THEN
|
|
SELECT ARRAY(SELECT jsonb_array_elements_text(p_credential->'transports'))
|
|
INTO v_transports;
|
|
END IF;
|
|
|
|
-- Check if user exists
|
|
IF NOT EXISTS (SELECT 1 FROM users WHERE id = v_user_id) THEN
|
|
RETURN QUERY SELECT false, 'User not found'::text, NULL::integer;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Insert credential
|
|
INSERT INTO user_passkey_credentials (
|
|
user_id, credential_id, public_key, attestation_type, aaguid,
|
|
sign_count, transports, backup_eligible, backup_state, name, created_at, last_used_at
|
|
)
|
|
VALUES (
|
|
v_user_id, v_cred_id, v_public_key, v_attestation_type, v_aaguid,
|
|
v_sign_count, v_transports, v_backup_eligible, v_backup_state, v_name, now(), now()
|
|
)
|
|
RETURNING id INTO v_credential_id;
|
|
|
|
RETURN QUERY SELECT true, NULL::text, v_credential_id;
|
|
EXCEPTION
|
|
WHEN unique_violation THEN
|
|
RETURN QUERY SELECT false, 'Credential already exists'::text, NULL::integer;
|
|
WHEN OTHERS THEN
|
|
RETURN QUERY SELECT false, SQLERRM::text, NULL::integer;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 2. resolvespec_passkey_get_credential - Get credential by credential_id
|
|
-- Input: p_credential_id (bytea)
|
|
-- Output: p_success (bool), p_error (text), p_credential (jsonb)
|
|
CREATE OR REPLACE FUNCTION resolvespec_passkey_get_credential(p_credential_id bytea)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_credential jsonb) AS $$
|
|
DECLARE
|
|
v_credential jsonb;
|
|
BEGIN
|
|
SELECT jsonb_build_object(
|
|
'id', id,
|
|
'user_id', user_id,
|
|
'credential_id', encode(credential_id, 'base64'),
|
|
'public_key', encode(public_key, 'base64'),
|
|
'attestation_type', attestation_type,
|
|
'aaguid', encode(COALESCE(aaguid, ''::bytea), 'base64'),
|
|
'sign_count', sign_count,
|
|
'clone_warning', clone_warning,
|
|
'transports', COALESCE(to_jsonb(transports), '[]'::jsonb),
|
|
'backup_eligible', backup_eligible,
|
|
'backup_state', backup_state,
|
|
'name', name,
|
|
'created_at', created_at,
|
|
'last_used_at', last_used_at
|
|
)
|
|
INTO v_credential
|
|
FROM user_passkey_credentials
|
|
WHERE credential_id = p_credential_id;
|
|
|
|
IF v_credential IS NULL THEN
|
|
RETURN QUERY SELECT false, 'Credential not found'::text, NULL::jsonb;
|
|
ELSE
|
|
RETURN QUERY SELECT true, NULL::text, v_credential;
|
|
END IF;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 3. resolvespec_passkey_get_user_credentials - Get all credentials for a user
|
|
-- Input: p_user_id (integer)
|
|
-- Output: p_success (bool), p_error (text), p_credentials (jsonb array)
|
|
CREATE OR REPLACE FUNCTION resolvespec_passkey_get_user_credentials(p_user_id integer)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_credentials jsonb) AS $$
|
|
DECLARE
|
|
v_credentials jsonb;
|
|
BEGIN
|
|
SELECT COALESCE(jsonb_agg(
|
|
jsonb_build_object(
|
|
'id', id,
|
|
'user_id', user_id,
|
|
'credential_id', encode(credential_id, 'base64'),
|
|
'public_key', encode(public_key, 'base64'),
|
|
'attestation_type', attestation_type,
|
|
'aaguid', encode(COALESCE(aaguid, ''::bytea), 'base64'),
|
|
'sign_count', sign_count,
|
|
'clone_warning', clone_warning,
|
|
'transports', COALESCE(to_jsonb(transports), '[]'::jsonb),
|
|
'backup_eligible', backup_eligible,
|
|
'backup_state', backup_state,
|
|
'name', name,
|
|
'created_at', created_at,
|
|
'last_used_at', last_used_at
|
|
)
|
|
), '[]'::jsonb)
|
|
INTO v_credentials
|
|
FROM user_passkey_credentials
|
|
WHERE user_id = p_user_id
|
|
ORDER BY created_at DESC;
|
|
|
|
RETURN QUERY SELECT true, NULL::text, v_credentials;
|
|
EXCEPTION
|
|
WHEN OTHERS THEN
|
|
RETURN QUERY SELECT false, SQLERRM::text, '[]'::jsonb;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 4. resolvespec_passkey_update_counter - Update sign counter and check for cloning
|
|
-- Input: p_credential_id (bytea), p_new_counter (integer)
|
|
-- Output: p_success (bool), p_error (text), p_clone_warning (bool)
|
|
CREATE OR REPLACE FUNCTION resolvespec_passkey_update_counter(
|
|
p_credential_id bytea,
|
|
p_new_counter integer
|
|
)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_clone_warning boolean) AS $$
|
|
DECLARE
|
|
v_old_counter INTEGER;
|
|
v_clone_warning BOOLEAN := false;
|
|
BEGIN
|
|
-- Get current counter
|
|
SELECT sign_count INTO v_old_counter
|
|
FROM user_passkey_credentials
|
|
WHERE credential_id = p_credential_id;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN QUERY SELECT false, 'Credential not found'::text, false;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Check for cloning (counter should always increase)
|
|
IF p_new_counter <= v_old_counter THEN
|
|
v_clone_warning := true;
|
|
|
|
-- Update clone warning flag
|
|
UPDATE user_passkey_credentials
|
|
SET clone_warning = true
|
|
WHERE credential_id = p_credential_id;
|
|
ELSE
|
|
-- Normal counter update
|
|
UPDATE user_passkey_credentials
|
|
SET sign_count = p_new_counter,
|
|
last_used_at = now()
|
|
WHERE credential_id = p_credential_id;
|
|
END IF;
|
|
|
|
RETURN QUERY SELECT true, NULL::text, v_clone_warning;
|
|
EXCEPTION
|
|
WHEN OTHERS THEN
|
|
RETURN QUERY SELECT false, SQLERRM::text, false;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 5. resolvespec_passkey_delete_credential - Delete a passkey credential
|
|
-- Input: p_user_id (integer), p_credential_id (bytea)
|
|
-- Output: p_success (bool), p_error (text)
|
|
CREATE OR REPLACE FUNCTION resolvespec_passkey_delete_credential(
|
|
p_user_id integer,
|
|
p_credential_id bytea
|
|
)
|
|
RETURNS TABLE(p_success boolean, p_error text) AS $$
|
|
DECLARE
|
|
v_deleted INTEGER;
|
|
BEGIN
|
|
DELETE FROM user_passkey_credentials
|
|
WHERE user_id = p_user_id AND credential_id = p_credential_id;
|
|
|
|
GET DIAGNOSTICS v_deleted = ROW_COUNT;
|
|
|
|
IF v_deleted = 0 THEN
|
|
RETURN QUERY SELECT false, 'Credential not found'::text;
|
|
ELSE
|
|
RETURN QUERY SELECT true, NULL::text;
|
|
END IF;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 6. resolvespec_passkey_update_name - Update credential friendly name
|
|
-- Input: p_user_id (integer), p_credential_id (bytea), p_name (text)
|
|
-- Output: p_success (bool), p_error (text)
|
|
CREATE OR REPLACE FUNCTION resolvespec_passkey_update_name(
|
|
p_user_id integer,
|
|
p_credential_id bytea,
|
|
p_name text
|
|
)
|
|
RETURNS TABLE(p_success boolean, p_error text) AS $$
|
|
DECLARE
|
|
v_updated INTEGER;
|
|
BEGIN
|
|
UPDATE user_passkey_credentials
|
|
SET name = p_name
|
|
WHERE user_id = p_user_id AND credential_id = p_credential_id;
|
|
|
|
GET DIAGNOSTICS v_updated = ROW_COUNT;
|
|
|
|
IF v_updated = 0 THEN
|
|
RETURN QUERY SELECT false, 'Credential not found'::text;
|
|
ELSE
|
|
RETURN QUERY SELECT true, NULL::text;
|
|
END IF;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 7. resolvespec_passkey_get_credentials_by_username - Get credentials for passkey authentication
|
|
-- Input: p_username (text)
|
|
-- Output: p_success (bool), p_error (text), p_user_id (int), p_credentials (jsonb array)
|
|
CREATE OR REPLACE FUNCTION resolvespec_passkey_get_credentials_by_username(p_username text)
|
|
RETURNS TABLE(p_success boolean, p_error text, p_user_id integer, p_credentials jsonb) AS $$
|
|
DECLARE
|
|
v_user_id INTEGER;
|
|
v_credentials jsonb;
|
|
BEGIN
|
|
-- Get user ID
|
|
SELECT id INTO v_user_id
|
|
FROM users
|
|
WHERE username = p_username AND is_active = true;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN QUERY SELECT false, 'User not found'::text, NULL::integer, NULL::jsonb;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Get user's credentials
|
|
SELECT COALESCE(jsonb_agg(
|
|
jsonb_build_object(
|
|
'id', id,
|
|
'credential_id', encode(credential_id, 'base64'),
|
|
'transports', COALESCE(to_jsonb(transports), '[]'::jsonb)
|
|
)
|
|
), '[]'::jsonb)
|
|
INTO v_credentials
|
|
FROM user_passkey_credentials
|
|
WHERE user_id = v_user_id;
|
|
|
|
RETURN QUERY SELECT true, NULL::text, v_user_id, v_credentials;
|
|
EXCEPTION
|
|
WHEN OTHERS THEN
|
|
RETURN QUERY SELECT false, SQLERRM::text, NULL::integer, NULL::jsonb;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- ============================================
|
|
-- Example: Test Passkey stored procedures
|
|
-- ============================================
|
|
|
|
-- Store credential
|
|
-- SELECT * FROM resolvespec_passkey_store_credential('{"user_id": 1, "credential_id": "YWJjZGVmMTIzNDU2", "public_key": "MIIBIjAN...", "attestation_type": "none", "sign_count": 0, "transports": ["internal"], "backup_eligible": true, "backup_state": false, "name": "My Phone"}'::jsonb);
|
|
|
|
-- Get credential
|
|
-- SELECT * FROM resolvespec_passkey_get_credential(decode('YWJjZGVmMTIzNDU2', 'base64'));
|
|
|
|
-- Get user credentials
|
|
-- SELECT * FROM resolvespec_passkey_get_user_credentials(1);
|
|
|
|
-- Update counter
|
|
-- SELECT * FROM resolvespec_passkey_update_counter(decode('YWJjZGVmMTIzNDU2', 'base64'), 1);
|
|
|
|
-- Delete credential
|
|
-- SELECT * FROM resolvespec_passkey_delete_credential(1, decode('YWJjZGVmMTIzNDU2', 'base64'));
|
|
|
|
-- Update name
|
|
-- SELECT * FROM resolvespec_passkey_update_name(1, decode('YWJjZGVmMTIzNDU2', 'base64'), 'New Name');
|
|
|
|
-- 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: The new password is hashed with bcrypt (pgcrypto crypt/gen_salt) before storing.
|
|
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;
|
|
|
|
IF octet_length(v_new_pw) > 72 THEN
|
|
RETURN QUERY SELECT false, 'new_password must be at most 72 bytes'::text;
|
|
RETURN;
|
|
END IF;
|
|
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": "<raw_token>", "new_password": "newpass123"}'::jsonb);
|
|
|
|
-- ============================================
|
|
-- OAuth2 Server Tables (OAuthServer persistence)
|
|
-- ============================================
|
|
|
|
-- oauth_clients: persistent RFC 7591 registered clients
|
|
CREATE TABLE IF NOT EXISTS oauth_clients (
|
|
id SERIAL PRIMARY KEY,
|
|
client_id VARCHAR(255) NOT NULL UNIQUE,
|
|
redirect_uris TEXT[] NOT NULL,
|
|
client_name VARCHAR(255),
|
|
grant_types TEXT[] DEFAULT ARRAY['authorization_code'],
|
|
allowed_scopes TEXT[] DEFAULT ARRAY['openid','profile','email'],
|
|
client_secret_hash TEXT, -- sha256 hex of the confidential-client secret; NULL for public clients
|
|
token_endpoint_auth_method VARCHAR(30) DEFAULT 'none',
|
|
is_active BOOLEAN DEFAULT true,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- oauth_codes: short-lived authorization codes (for multi-instance deployments)
|
|
-- Note: client_id is stored without a foreign key so codes can be persisted even
|
|
-- when OAuth clients are managed in memory rather than persisted in oauth_clients.
|
|
CREATE TABLE IF NOT EXISTS oauth_codes (
|
|
id SERIAL PRIMARY KEY,
|
|
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[],
|
|
expires_at TIMESTAMP NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_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);
|
|
|
|
-- ============================================
|
|
-- OAuth2 Server Stored Procedures
|
|
-- ============================================
|
|
|
|
CREATE OR REPLACE FUNCTION resolvespec_oauth_register_client(p_data jsonb)
|
|
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
|
|
LANGUAGE plpgsql AS $$
|
|
DECLARE
|
|
v_client_id text;
|
|
v_row jsonb;
|
|
BEGIN
|
|
v_client_id := p_data->>'client_id';
|
|
|
|
INSERT INTO oauth_clients (client_id, redirect_uris, client_name, grant_types, allowed_scopes, client_secret_hash, token_endpoint_auth_method)
|
|
VALUES (
|
|
v_client_id,
|
|
ARRAY(SELECT jsonb_array_elements_text(p_data->'redirect_uris')),
|
|
p_data->>'client_name',
|
|
COALESCE(ARRAY(SELECT jsonb_array_elements_text(p_data->'grant_types')), ARRAY['authorization_code']),
|
|
COALESCE(ARRAY(SELECT jsonb_array_elements_text(p_data->'allowed_scopes')), ARRAY['openid','profile','email']),
|
|
NULLIF(p_data->>'client_secret_hash', ''),
|
|
COALESCE(NULLIF(p_data->>'token_endpoint_auth_method', ''), 'none')
|
|
)
|
|
RETURNING to_jsonb(oauth_clients.*) INTO v_row;
|
|
|
|
RETURN QUERY SELECT true, null::text, v_row;
|
|
EXCEPTION WHEN OTHERS THEN
|
|
RETURN QUERY SELECT false, SQLERRM, null::jsonb;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION resolvespec_oauth_get_client(p_client_id text)
|
|
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
|
|
LANGUAGE plpgsql AS $$
|
|
DECLARE
|
|
v_row jsonb;
|
|
BEGIN
|
|
SELECT to_jsonb(oauth_clients.*)
|
|
INTO v_row
|
|
FROM oauth_clients
|
|
WHERE client_id = p_client_id AND is_active = true;
|
|
|
|
IF v_row IS NULL THEN
|
|
RETURN QUERY SELECT false, 'client not found'::text, null::jsonb;
|
|
ELSE
|
|
RETURN QUERY SELECT true, null::text, v_row;
|
|
END IF;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION resolvespec_oauth_save_code(p_data jsonb)
|
|
RETURNS TABLE(p_success bool, p_error text)
|
|
LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
INSERT INTO oauth_codes (code, client_id, redirect_uri, client_state, code_challenge, code_challenge_method, session_token, refresh_token, scopes, expires_at)
|
|
VALUES (
|
|
p_data->>'code',
|
|
p_data->>'client_id',
|
|
p_data->>'redirect_uri',
|
|
p_data->>'client_state',
|
|
p_data->>'code_challenge',
|
|
COALESCE(p_data->>'code_challenge_method', 'S256'),
|
|
p_data->>'session_token',
|
|
p_data->>'refresh_token',
|
|
ARRAY(SELECT jsonb_array_elements_text(p_data->'scopes')),
|
|
(p_data->>'expires_at')::timestamp
|
|
);
|
|
|
|
RETURN QUERY SELECT true, null::text;
|
|
EXCEPTION WHEN OTHERS THEN
|
|
RETURN QUERY SELECT false, SQLERRM;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION resolvespec_oauth_exchange_code(p_code text)
|
|
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
|
|
LANGUAGE plpgsql AS $$
|
|
DECLARE
|
|
v_row jsonb;
|
|
BEGIN
|
|
DELETE FROM oauth_codes
|
|
WHERE code = p_code AND expires_at > now()
|
|
RETURNING jsonb_build_object(
|
|
'client_id', client_id,
|
|
'redirect_uri', redirect_uri,
|
|
'client_state', client_state,
|
|
'code_challenge', code_challenge,
|
|
'code_challenge_method', code_challenge_method,
|
|
'session_token', session_token,
|
|
'refresh_token', refresh_token,
|
|
'scopes', to_jsonb(scopes)
|
|
) INTO v_row;
|
|
|
|
IF v_row IS NULL THEN
|
|
RETURN QUERY SELECT false, 'invalid or expired code'::text, null::jsonb;
|
|
ELSE
|
|
RETURN QUERY SELECT true, null::text, v_row;
|
|
END IF;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION resolvespec_oauth_introspect(p_token text)
|
|
RETURNS TABLE(p_success bool, p_error text, p_data jsonb)
|
|
LANGUAGE plpgsql AS $$
|
|
DECLARE
|
|
v_row jsonb;
|
|
BEGIN
|
|
SELECT jsonb_build_object(
|
|
'active', true,
|
|
'sub', u.id::text,
|
|
'username', u.username,
|
|
'email', u.email,
|
|
'user_level', u.user_level,
|
|
-- NULLIF converts empty string to NULL; string_to_array(NULL) returns NULL;
|
|
-- to_jsonb(NULL) returns NULL; COALESCE then returns '[]' for NULL/empty roles.
|
|
'roles', COALESCE(to_jsonb(string_to_array(NULLIF(u.roles, ''), ',')), '[]'::jsonb),
|
|
'exp', EXTRACT(EPOCH FROM s.expires_at)::bigint,
|
|
'iat', EXTRACT(EPOCH FROM s.created_at)::bigint
|
|
)
|
|
INTO v_row
|
|
FROM user_sessions s
|
|
JOIN users u ON u.id = s.user_id
|
|
WHERE s.session_token = p_token
|
|
AND s.expires_at > now()
|
|
AND u.is_active = true;
|
|
|
|
IF v_row IS NULL THEN
|
|
RETURN QUERY SELECT true, null::text, '{"active":false}'::jsonb;
|
|
ELSE
|
|
RETURN QUERY SELECT true, null::text, v_row;
|
|
END IF;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION resolvespec_oauth_revoke(p_token text)
|
|
RETURNS TABLE(p_success bool, p_error text)
|
|
LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
DELETE FROM user_sessions WHERE session_token = p_token;
|
|
RETURN QUERY SELECT true, null::text;
|
|
END;
|
|
$$;
|