Add support for OTPs and enhance sensitive apis with OTP verification (#191)
One-Time Password (OTP) package added with comprehensive token management, including OTP verification for team account deletion and ownership transfer.
This commit is contained in:
committed by
GitHub
parent
20f7fd2c22
commit
d31f3eb993
346
apps/web/supabase/migrations/20240610000000_one_time_tokens.sql
Normal file
346
apps/web/supabase/migrations/20240610000000_one_time_tokens.sql
Normal file
@@ -0,0 +1,346 @@
|
||||
create extension if not exists pg_cron;
|
||||
|
||||
-- Create a table to store one-time tokens (nonces)
|
||||
CREATE TABLE IF NOT EXISTS public.nonces (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_token TEXT NOT NULL, -- token sent to client (hashed)
|
||||
nonce TEXT NOT NULL, -- token stored in DB (hashed)
|
||||
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NULL, -- Optional to support anonymous tokens
|
||||
purpose TEXT NOT NULL, -- e.g., 'password-reset', 'email-verification', etc.
|
||||
|
||||
-- Status fields
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
used_at TIMESTAMPTZ,
|
||||
revoked BOOLEAN NOT NULL DEFAULT FALSE, -- For administrative revocation
|
||||
revoked_reason TEXT, -- Reason for revocation if applicable
|
||||
|
||||
-- Audit fields
|
||||
verification_attempts INTEGER NOT NULL DEFAULT 0, -- Track attempted uses
|
||||
last_verification_at TIMESTAMPTZ, -- Timestamp of last verification attempt
|
||||
last_verification_ip INET, -- For tracking verification source
|
||||
last_verification_user_agent TEXT, -- For tracking client information
|
||||
|
||||
-- Extensibility fields
|
||||
metadata JSONB DEFAULT '{}'::JSONB, -- optional metadata
|
||||
scopes TEXT[] DEFAULT '{}' -- OAuth-style authorized scopes
|
||||
);
|
||||
|
||||
-- Create indexes for efficient lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_nonces_status ON public.nonces (client_token, user_id, purpose, expires_at)
|
||||
WHERE used_at IS NULL AND revoked = FALSE;
|
||||
|
||||
-- Enable Row Level Security (RLS)
|
||||
ALTER TABLE public.nonces ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS policies
|
||||
-- Users can view their own nonces for verification
|
||||
CREATE POLICY "Users can read their own nonces"
|
||||
ON public.nonces
|
||||
FOR SELECT
|
||||
USING (
|
||||
user_id = (select auth.uid())
|
||||
);
|
||||
|
||||
-- Create a function to create a nonce
|
||||
CREATE OR REPLACE FUNCTION public.create_nonce(
|
||||
p_user_id UUID DEFAULT NULL,
|
||||
p_purpose TEXT DEFAULT NULL,
|
||||
p_expires_in_seconds INTEGER DEFAULT 3600, -- 1 hour by default
|
||||
p_metadata JSONB DEFAULT NULL,
|
||||
p_scopes TEXT[] DEFAULT NULL,
|
||||
p_revoke_previous BOOLEAN DEFAULT TRUE -- New parameter to control automatic revocation
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_client_token TEXT;
|
||||
v_nonce TEXT;
|
||||
v_expires_at TIMESTAMPTZ;
|
||||
v_id UUID;
|
||||
v_plaintext_token TEXT;
|
||||
v_revoked_count INTEGER;
|
||||
BEGIN
|
||||
-- Revoke previous tokens for the same user and purpose if requested
|
||||
-- This only applies if a user ID is provided (not for anonymous tokens)
|
||||
IF p_revoke_previous = TRUE AND p_user_id IS NOT NULL THEN
|
||||
WITH revoked AS (
|
||||
UPDATE public.nonces
|
||||
SET
|
||||
revoked = TRUE,
|
||||
revoked_reason = 'Superseded by new token with same purpose'
|
||||
WHERE
|
||||
user_id = p_user_id
|
||||
AND purpose = p_purpose
|
||||
AND used_at IS NULL
|
||||
AND revoked = FALSE
|
||||
AND expires_at > NOW()
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*) INTO v_revoked_count FROM revoked;
|
||||
END IF;
|
||||
|
||||
-- Generate a 6-digit token
|
||||
v_plaintext_token := (100000 + floor(random() * 900000))::text;
|
||||
v_client_token := crypt(v_plaintext_token, gen_salt('bf'));
|
||||
|
||||
-- Still generate a secure nonce for internal use
|
||||
v_nonce := encode(gen_random_bytes(24), 'base64');
|
||||
v_nonce := crypt(v_nonce, gen_salt('bf'));
|
||||
|
||||
-- Calculate expiration time
|
||||
v_expires_at := NOW() + (p_expires_in_seconds * interval '1 second');
|
||||
|
||||
-- Insert the new nonce
|
||||
INSERT INTO public.nonces (
|
||||
client_token,
|
||||
nonce,
|
||||
user_id,
|
||||
expires_at,
|
||||
metadata,
|
||||
purpose,
|
||||
scopes
|
||||
)
|
||||
VALUES (
|
||||
v_client_token,
|
||||
v_nonce,
|
||||
p_user_id,
|
||||
v_expires_at,
|
||||
COALESCE(p_metadata, '{}'::JSONB),
|
||||
p_purpose,
|
||||
COALESCE(p_scopes, '{}'::TEXT[])
|
||||
)
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
-- Return the token information
|
||||
-- Note: returning the plaintext token, not the hash
|
||||
RETURN jsonb_build_object(
|
||||
'id', v_id,
|
||||
'token', v_plaintext_token,
|
||||
'expires_at', v_expires_at,
|
||||
'revoked_previous_count', COALESCE(v_revoked_count, 0)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
grant execute on function public.create_nonce to service_role;
|
||||
|
||||
-- Create a function to verify a nonce
|
||||
CREATE OR REPLACE FUNCTION public.verify_nonce(
|
||||
p_token TEXT,
|
||||
p_purpose TEXT,
|
||||
p_user_id UUID DEFAULT NULL,
|
||||
p_required_scopes TEXT[] DEFAULT NULL,
|
||||
p_max_verification_attempts INTEGER DEFAULT 5,
|
||||
p_ip INET DEFAULT NULL,
|
||||
p_user_agent TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_nonce RECORD;
|
||||
v_matching_count INTEGER;
|
||||
BEGIN
|
||||
-- Add debugging info
|
||||
RAISE NOTICE 'Verifying token: %, purpose: %, user_id: %', p_token, p_purpose, p_user_id;
|
||||
|
||||
-- Count how many matching tokens exist before verification attempt
|
||||
SELECT COUNT(*) INTO v_matching_count
|
||||
FROM public.nonces
|
||||
WHERE purpose = p_purpose;
|
||||
|
||||
-- Update verification attempt counter and tracking info for all matching tokens
|
||||
UPDATE public.nonces
|
||||
SET
|
||||
verification_attempts = verification_attempts + 1,
|
||||
last_verification_at = NOW(),
|
||||
last_verification_ip = COALESCE(p_ip, last_verification_ip),
|
||||
last_verification_user_agent = COALESCE(p_user_agent, last_verification_user_agent)
|
||||
WHERE
|
||||
client_token = crypt(p_token, client_token)
|
||||
AND purpose = p_purpose;
|
||||
|
||||
-- Find the nonce by token and purpose
|
||||
-- Modified to handle user-specific tokens better
|
||||
SELECT *
|
||||
INTO v_nonce
|
||||
FROM public.nonces
|
||||
WHERE
|
||||
client_token = crypt(p_token, client_token)
|
||||
AND purpose = p_purpose
|
||||
-- Only apply user_id filter if the token was created for a specific user
|
||||
AND (
|
||||
-- Case 1: Anonymous token (user_id is NULL in DB)
|
||||
(user_id IS NULL)
|
||||
OR
|
||||
-- Case 2: User-specific token (check if user_id matches)
|
||||
(user_id = p_user_id)
|
||||
)
|
||||
AND used_at IS NULL
|
||||
AND NOT revoked
|
||||
AND expires_at > NOW();
|
||||
|
||||
-- Check if nonce exists
|
||||
IF v_nonce.id IS NULL THEN
|
||||
RETURN jsonb_build_object(
|
||||
'valid', false,
|
||||
'message', 'Invalid or expired token'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Check if max verification attempts exceeded
|
||||
IF p_max_verification_attempts > 0 AND v_nonce.verification_attempts > p_max_verification_attempts THEN
|
||||
-- Automatically revoke the token
|
||||
UPDATE public.nonces
|
||||
SET
|
||||
revoked = TRUE,
|
||||
revoked_reason = 'Maximum verification attempts exceeded'
|
||||
WHERE id = v_nonce.id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'valid', false,
|
||||
'message', 'Token revoked due to too many verification attempts',
|
||||
'max_attempts_exceeded', true
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Check scopes if required
|
||||
IF p_required_scopes IS NOT NULL AND array_length(p_required_scopes, 1) > 0 THEN
|
||||
-- Fix scope validation to properly check if token scopes contain all required scopes
|
||||
-- Using array containment check: array1 @> array2 (array1 contains array2)
|
||||
IF NOT (v_nonce.scopes @> p_required_scopes) THEN
|
||||
RETURN jsonb_build_object(
|
||||
'valid', false,
|
||||
'message', 'Token does not have required permissions',
|
||||
'token_scopes', v_nonce.scopes,
|
||||
'required_scopes', p_required_scopes
|
||||
);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Mark nonce as used
|
||||
UPDATE public.nonces
|
||||
SET used_at = NOW()
|
||||
WHERE id = v_nonce.id;
|
||||
|
||||
-- Return success with metadata
|
||||
RETURN jsonb_build_object(
|
||||
'valid', true,
|
||||
'user_id', v_nonce.user_id,
|
||||
'metadata', v_nonce.metadata,
|
||||
'scopes', v_nonce.scopes,
|
||||
'purpose', v_nonce.purpose
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
grant execute on function public.verify_nonce to authenticated,service_role;
|
||||
|
||||
-- Create a function to revoke a nonce
|
||||
CREATE OR REPLACE FUNCTION public.revoke_nonce(
|
||||
p_id UUID,
|
||||
p_reason TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_affected_rows INTEGER;
|
||||
BEGIN
|
||||
UPDATE public.nonces
|
||||
SET
|
||||
revoked = TRUE,
|
||||
revoked_reason = p_reason
|
||||
WHERE
|
||||
id = p_id
|
||||
AND used_at IS NULL
|
||||
AND NOT revoked
|
||||
RETURNING 1 INTO v_affected_rows;
|
||||
|
||||
RETURN v_affected_rows > 0;
|
||||
END;
|
||||
$$;
|
||||
|
||||
grant execute on function public.revoke_nonce to service_role;
|
||||
|
||||
-- Create a function to clean up expired nonces
|
||||
CREATE OR REPLACE FUNCTION kit.cleanup_expired_nonces(
|
||||
p_older_than_days INTEGER DEFAULT 1,
|
||||
p_include_used BOOLEAN DEFAULT TRUE,
|
||||
p_include_revoked BOOLEAN DEFAULT TRUE
|
||||
)
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_count INTEGER;
|
||||
BEGIN
|
||||
-- Count and delete expired or used nonces based on parameters
|
||||
WITH deleted AS (
|
||||
DELETE FROM public.nonces
|
||||
WHERE
|
||||
(
|
||||
-- Expired and unused tokens
|
||||
(expires_at < NOW() AND used_at IS NULL)
|
||||
|
||||
-- Used tokens older than specified days (if enabled)
|
||||
OR (p_include_used = TRUE AND used_at < NOW() - (p_older_than_days * interval '1 day'))
|
||||
|
||||
-- Revoked tokens older than specified days (if enabled)
|
||||
OR (p_include_revoked = TRUE AND revoked = TRUE AND created_at < NOW() - (p_older_than_days * interval '1 day'))
|
||||
)
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*) INTO v_count FROM deleted;
|
||||
|
||||
RETURN v_count;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create a function to get token status (for administrative use)
|
||||
CREATE OR REPLACE FUNCTION public.get_nonce_status(
|
||||
p_id UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_nonce public.nonces;
|
||||
BEGIN
|
||||
SELECT * INTO v_nonce FROM public.nonces WHERE id = p_id;
|
||||
|
||||
IF v_nonce.id IS NULL THEN
|
||||
RETURN jsonb_build_object('exists', false);
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'exists', true,
|
||||
'purpose', v_nonce.purpose,
|
||||
'user_id', v_nonce.user_id,
|
||||
'created_at', v_nonce.created_at,
|
||||
'expires_at', v_nonce.expires_at,
|
||||
'used_at', v_nonce.used_at,
|
||||
'revoked', v_nonce.revoked,
|
||||
'revoked_reason', v_nonce.revoked_reason,
|
||||
'verification_attempts', v_nonce.verification_attempts,
|
||||
'last_verification_at', v_nonce.last_verification_at,
|
||||
'last_verification_ip', v_nonce.last_verification_ip,
|
||||
'is_valid', (v_nonce.used_at IS NULL AND NOT v_nonce.revoked AND v_nonce.expires_at > NOW())
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Comments for documentation
|
||||
COMMENT ON TABLE public.nonces IS 'Table for storing one-time tokens with enhanced security and audit features';
|
||||
COMMENT ON FUNCTION public.create_nonce IS 'Creates a new one-time token for a specific purpose with enhanced options';
|
||||
COMMENT ON FUNCTION public.verify_nonce IS 'Verifies a one-time token, checks scopes, and marks it as used';
|
||||
COMMENT ON FUNCTION public.revoke_nonce IS 'Administratively revokes a token to prevent its use';
|
||||
COMMENT ON FUNCTION kit.cleanup_expired_nonces IS 'Cleans up expired, used, or revoked tokens based on parameters';
|
||||
COMMENT ON FUNCTION public.get_nonce_status IS 'Retrieves the status of a token for administrative purposes';
|
||||
@@ -10,6 +10,19 @@ alter default PRIVILEGES in schema makerkit revoke execute on FUNCTIONS from pub
|
||||
alter default PRIVILEGES in schema makerkit grant execute on FUNCTIONS to anon,
|
||||
authenticated, service_role;
|
||||
|
||||
create or replace function makerkit.get_id_by_identifier(
|
||||
identifier text
|
||||
)
|
||||
returns uuid
|
||||
as $$
|
||||
begin
|
||||
|
||||
return (select id from auth.users where raw_user_meta_data->>'test_identifier' = identifier);
|
||||
|
||||
end;
|
||||
|
||||
$$ language PLPGSQL;
|
||||
|
||||
create or replace function makerkit.set_identifier(
|
||||
identifier text,
|
||||
user_email text
|
||||
|
||||
853
apps/web/supabase/tests/database/otp.test.sql
Normal file
853
apps/web/supabase/tests/database/otp.test.sql
Normal file
@@ -0,0 +1,853 @@
|
||||
begin;
|
||||
create extension "basejump-supabase_test_helpers" version '0.0.6';
|
||||
|
||||
select no_plan(); -- Use no_plan for flexibility
|
||||
|
||||
select tests.create_supabase_user('token_creator', 'creator@example.com');
|
||||
select tests.create_supabase_user('token_verifier', 'verifier@example.com');
|
||||
|
||||
-- ==========================================
|
||||
-- Test 1: Permission Tests
|
||||
-- ==========================================
|
||||
|
||||
-- Test 1.1: Regular users cannot create nonces directly
|
||||
select tests.authenticate_as('token_creator');
|
||||
|
||||
select throws_ok(
|
||||
$$ select public.create_nonce(auth.uid(), 'password-reset', 3600) $$,
|
||||
'permission denied for function create_nonce',
|
||||
'Regular users should not be able to create nonces directly'
|
||||
);
|
||||
|
||||
-- Test 1.2: Regular users cannot revoke nonces
|
||||
select throws_ok(
|
||||
$$ select public.revoke_nonce('00000000-0000-0000-0000-000000000000'::uuid, 'test') $$,
|
||||
'permission denied for function revoke_nonce',
|
||||
'Regular users should not be able to revoke tokens'
|
||||
);
|
||||
|
||||
-- Test 1.3: Service role can create nonces
|
||||
set local role service_role;
|
||||
|
||||
-- Create a token and store it for later verification
|
||||
do $$
|
||||
declare
|
||||
token_result jsonb;
|
||||
begin
|
||||
token_result := public.create_nonce(
|
||||
null,
|
||||
'password-reset',
|
||||
3600,
|
||||
'{"redirect_url": "/reset-password"}'::jsonb,
|
||||
ARRAY['auth:reset']
|
||||
);
|
||||
|
||||
-- Store the token for later verification
|
||||
perform set_config('app.settings.test_token', token_result->>'token', false);
|
||||
perform set_config('app.settings.test_token_id', token_result->>'id', false);
|
||||
perform set_config('app.settings.test_token_json', token_result::text, false);
|
||||
end $$;
|
||||
|
||||
-- Check token result properties
|
||||
select ok(
|
||||
(current_setting('app.settings.test_token_json', false)::jsonb) ? 'id',
|
||||
'Token result should contain an id'
|
||||
);
|
||||
select ok(
|
||||
(current_setting('app.settings.test_token_json', false)::jsonb) ? 'token',
|
||||
'Token result should contain a token'
|
||||
);
|
||||
select ok(
|
||||
(current_setting('app.settings.test_token_json', false)::jsonb) ? 'expires_at',
|
||||
'Token result should contain an expiration time'
|
||||
);
|
||||
|
||||
set local role postgres;
|
||||
|
||||
-- Create a token for an authenticated user
|
||||
do $$
|
||||
declare
|
||||
token_result jsonb;
|
||||
auth_user_id uuid;
|
||||
begin
|
||||
auth_user_id := makerkit.get_id_by_identifier('token_creator');
|
||||
|
||||
token_result := public.create_nonce(
|
||||
auth_user_id,
|
||||
'email-verification',
|
||||
3600
|
||||
);
|
||||
|
||||
-- Store the token for later user verification
|
||||
perform set_config('app.settings.user_token', token_result->>'token', false);
|
||||
perform set_config('app.settings.user_token_id', token_result->>'id', false);
|
||||
perform set_config('app.settings.user_token_json', token_result::text, false);
|
||||
end $$;
|
||||
|
||||
-- Check user token result properties
|
||||
select ok(
|
||||
(current_setting('app.settings.user_token_json', false)::jsonb) ? 'id',
|
||||
'Token result with minimal params should contain an id'
|
||||
);
|
||||
select ok(
|
||||
(current_setting('app.settings.user_token_json', false)::jsonb) ? 'token',
|
||||
'Token result with minimal params should contain a token'
|
||||
);
|
||||
|
||||
-- Create an anonymous token (no user_id)
|
||||
do $$
|
||||
declare
|
||||
token_result jsonb;
|
||||
begin
|
||||
token_result := public.create_nonce(
|
||||
null,
|
||||
'user-invitation',
|
||||
7200,
|
||||
'{"team_id": "123456"}'::jsonb
|
||||
);
|
||||
|
||||
-- Store the anonymous token for later verification
|
||||
perform set_config('app.settings.anonymous_token', token_result->>'token', false);
|
||||
|
||||
perform set_config('app.settings.anonymous_token_id', token_result->>'id', false);
|
||||
|
||||
perform set_config('app.settings.anonymous_token_json', token_result::text, false);
|
||||
end $$;
|
||||
|
||||
-- Check anonymous token result properties
|
||||
select ok(
|
||||
(current_setting('app.settings.anonymous_token_json', false)::jsonb) ? 'id',
|
||||
'Anonymous token result should contain an id'
|
||||
);
|
||||
|
||||
select ok(
|
||||
(current_setting('app.settings.anonymous_token_json', false)::jsonb) ? 'token',
|
||||
'Anonymous token result should contain a token'
|
||||
);
|
||||
|
||||
-- ==========================================
|
||||
-- Test 2: Verify Tokens
|
||||
-- ==========================================
|
||||
|
||||
-- Test 2.1: Authenticated users can verify tokens
|
||||
select tests.authenticate_as('token_creator');
|
||||
|
||||
-- Verify token and store result
|
||||
do $$
|
||||
declare
|
||||
test_token text;
|
||||
verification_result jsonb;
|
||||
begin
|
||||
test_token := current_setting('app.settings.test_token', false);
|
||||
|
||||
verification_result := public.verify_nonce(
|
||||
test_token,
|
||||
'password-reset'
|
||||
);
|
||||
|
||||
perform set_config('app.settings.verification_result', verification_result::text, false);
|
||||
end $$;
|
||||
|
||||
-- Check verification result
|
||||
select is(
|
||||
(current_setting('app.settings.verification_result', false)::jsonb)->>'valid',
|
||||
'true',
|
||||
'Token should be valid'
|
||||
);
|
||||
|
||||
select ok(
|
||||
(current_setting('app.settings.verification_result', false)::jsonb) ? 'metadata',
|
||||
'Result should contain metadata'
|
||||
);
|
||||
|
||||
select ok(
|
||||
(current_setting('app.settings.verification_result', false)::jsonb) ? 'scopes',
|
||||
'Result should contain scopes'
|
||||
);
|
||||
|
||||
-- Test 2.2: Users can verify tokens assigned to them
|
||||
do $$
|
||||
declare
|
||||
user_token text;
|
||||
verification_result jsonb;
|
||||
user_id uuid;
|
||||
begin
|
||||
user_token := current_setting('app.settings.user_token', false);
|
||||
|
||||
set local role postgres;
|
||||
user_id := makerkit.get_id_by_identifier('token_creator');
|
||||
|
||||
perform tests.authenticate_as('token_creator');
|
||||
|
||||
verification_result := public.verify_nonce(
|
||||
user_token,
|
||||
'email-verification',
|
||||
user_id
|
||||
);
|
||||
|
||||
perform set_config('app.settings.user_verification_result', verification_result::text, false);
|
||||
end $$;
|
||||
|
||||
-- Check user verification result
|
||||
select is(
|
||||
(current_setting('app.settings.user_verification_result', false)::jsonb)->>'valid',
|
||||
'true',
|
||||
'User-specific token should be valid'
|
||||
);
|
||||
|
||||
select isnt(
|
||||
(current_setting('app.settings.user_verification_result', false)::jsonb)->>'user_id',
|
||||
null,
|
||||
'User-specific token should have user_id'
|
||||
);
|
||||
|
||||
-- Test 2.3: Verify token with scopes
|
||||
set local role service_role;
|
||||
|
||||
-- Create token with scopes
|
||||
do $$
|
||||
declare
|
||||
scope_token_result jsonb;
|
||||
begin
|
||||
-- Create token with scopes
|
||||
scope_token_result := public.create_nonce(
|
||||
null,
|
||||
'api-access',
|
||||
3600,
|
||||
'{"permissions": "read-only"}'::jsonb,
|
||||
ARRAY['read:profile', 'read:posts']
|
||||
);
|
||||
|
||||
-- Store for verification
|
||||
perform set_config('app.settings.scope_token', scope_token_result->>'token', false);
|
||||
end $$;
|
||||
|
||||
-- Verify with correct scope
|
||||
do $$
|
||||
declare
|
||||
scope_token text;
|
||||
verification_result jsonb;
|
||||
user_id uuid;
|
||||
begin
|
||||
set local role postgres;
|
||||
scope_token := current_setting('app.settings.scope_token', false);
|
||||
user_id := makerkit.get_id_by_identifier('token_verifier');
|
||||
|
||||
perform tests.authenticate_as('token_verifier');
|
||||
|
||||
-- Verify with correct required scope
|
||||
verification_result := public.verify_nonce(
|
||||
scope_token,
|
||||
'api-access',
|
||||
null,
|
||||
ARRAY['read:profile']
|
||||
);
|
||||
|
||||
perform set_config('app.settings.correct_scope_result', verification_result::text, false);
|
||||
|
||||
-- Verify with incorrect required scope
|
||||
verification_result := public.verify_nonce(
|
||||
scope_token,
|
||||
'api-access',
|
||||
null,
|
||||
ARRAY['write:posts']
|
||||
);
|
||||
|
||||
perform set_config('app.settings.incorrect_scope_result', verification_result::text, false);
|
||||
end $$;
|
||||
|
||||
-- Check scope verification results
|
||||
select is(
|
||||
(current_setting('app.settings.correct_scope_result', false)::jsonb)->>'valid',
|
||||
'true',
|
||||
'Token with correct scope should be valid'
|
||||
);
|
||||
|
||||
select is(
|
||||
(current_setting('app.settings.incorrect_scope_result', false)::jsonb)->>'valid',
|
||||
'false',
|
||||
'Token with incorrect scope should be invalid'
|
||||
);
|
||||
|
||||
-- Test 2.4: Once used, token becomes invalid
|
||||
do $$
|
||||
declare
|
||||
token_result jsonb;
|
||||
first_verification jsonb;
|
||||
second_verification jsonb;
|
||||
begin
|
||||
-- Use service role to create a token
|
||||
set local role service_role;
|
||||
|
||||
-- Create a token
|
||||
token_result := public.create_nonce(
|
||||
null,
|
||||
'one-time-action',
|
||||
3600
|
||||
);
|
||||
|
||||
set local role authenticated;
|
||||
|
||||
-- Verify it once (uses it)
|
||||
first_verification := public.verify_nonce(
|
||||
token_result->>'token',
|
||||
'one-time-action'
|
||||
);
|
||||
|
||||
-- Try to verify again
|
||||
second_verification := public.verify_nonce(
|
||||
token_result->>'token',
|
||||
'one-time-action'
|
||||
);
|
||||
|
||||
perform set_config('app.settings.first_verification', first_verification::text, false);
|
||||
perform set_config('app.settings.second_verification', second_verification::text, false);
|
||||
end $$;
|
||||
|
||||
-- Check first and second verification results
|
||||
select is(
|
||||
(current_setting('app.settings.first_verification', false)::jsonb)->>'valid',
|
||||
'true',
|
||||
'First verification should succeed'
|
||||
);
|
||||
select is(
|
||||
(current_setting('app.settings.second_verification', false)::jsonb)->>'valid',
|
||||
'false',
|
||||
'Token should not be valid on second use'
|
||||
);
|
||||
|
||||
-- Test 2.5: Verify with incorrect purpose
|
||||
do $$
|
||||
declare
|
||||
token_result jsonb;
|
||||
verification_result jsonb;
|
||||
begin
|
||||
-- Use service role to create a token
|
||||
set local role service_role;
|
||||
|
||||
-- Create a token
|
||||
token_result := public.create_nonce(
|
||||
null,
|
||||
'specific-purpose',
|
||||
3600
|
||||
);
|
||||
|
||||
set local role authenticated;
|
||||
|
||||
-- Verify with wrong purpose
|
||||
verification_result := public.verify_nonce(
|
||||
token_result->>'token',
|
||||
'different-purpose'
|
||||
);
|
||||
|
||||
perform set_config('app.settings.wrong_purpose_result', verification_result::text, false);
|
||||
end $$;
|
||||
|
||||
-- Check wrong purpose verification result
|
||||
select is(
|
||||
(current_setting('app.settings.wrong_purpose_result', false)::jsonb)->>'valid',
|
||||
'false',
|
||||
'Token with incorrect purpose should be invalid'
|
||||
);
|
||||
|
||||
-- ==========================================
|
||||
-- Test 3: Revoke Tokens
|
||||
-- ==========================================
|
||||
|
||||
-- Test 3.1: Only service_role can revoke tokens
|
||||
select tests.authenticate_as('token_creator');
|
||||
|
||||
select
|
||||
has_function(
|
||||
'public',
|
||||
'revoke_nonce',
|
||||
ARRAY['uuid', 'text'],
|
||||
'revoke_nonce function should exist'
|
||||
);
|
||||
|
||||
select throws_ok(
|
||||
$$ select public.revoke_nonce('00000000-0000-0000-0000-000000000000'::uuid, 'test reason') $$,
|
||||
'permission denied for function revoke_nonce',
|
||||
'Regular users should not be able to revoke tokens'
|
||||
);
|
||||
|
||||
-- Test 3.2: Service role can revoke tokens
|
||||
set local role service_role;
|
||||
|
||||
do $$
|
||||
declare
|
||||
token_result jsonb;
|
||||
revoke_result boolean;
|
||||
verification_result jsonb;
|
||||
token_id uuid;
|
||||
begin
|
||||
-- Create a token
|
||||
token_result := public.create_nonce(
|
||||
null,
|
||||
'revokable-action',
|
||||
3600
|
||||
);
|
||||
|
||||
token_id := token_result->>'id';
|
||||
|
||||
-- Revoke the token
|
||||
revoke_result := public.revoke_nonce(
|
||||
token_id,
|
||||
'Security concern'
|
||||
);
|
||||
|
||||
-- Switch to regular user to try to verify the revoked token
|
||||
set local role authenticated;
|
||||
|
||||
-- Try to verify the revoked token
|
||||
verification_result := public.verify_nonce(
|
||||
token_result->>'token',
|
||||
'revokable-action'
|
||||
);
|
||||
|
||||
perform set_config('app.settings.revoke_result', revoke_result::text, false);
|
||||
perform set_config('app.settings.revoked_verification', verification_result::text, false);
|
||||
end $$;
|
||||
|
||||
-- Check revocation results
|
||||
select is(
|
||||
current_setting('app.settings.revoke_result', false)::boolean,
|
||||
true,
|
||||
'Token revocation should succeed'
|
||||
);
|
||||
select is(
|
||||
(current_setting('app.settings.revoked_verification', false)::jsonb)->>'valid',
|
||||
'false',
|
||||
'Revoked token should be invalid'
|
||||
);
|
||||
|
||||
-- ==========================================
|
||||
-- Test 4: Get Token Status
|
||||
-- ==========================================
|
||||
|
||||
-- Test 4.1: Verify permission on get_nonce_status
|
||||
select tests.authenticate_as('token_creator');
|
||||
|
||||
select throws_ok(
|
||||
$$ select public.get_nonce_status('00000000-0000-0000-0000-000000000000'::uuid) $$,
|
||||
'permission denied for function get_nonce_status',
|
||||
'Regular users should not be able to check token status'
|
||||
);
|
||||
|
||||
-- Test 4.2: Service role can check token status
|
||||
set local role service_role;
|
||||
|
||||
select
|
||||
has_function(
|
||||
'public',
|
||||
'get_nonce_status',
|
||||
ARRAY['uuid'],
|
||||
'get_nonce_status function should exist'
|
||||
);
|
||||
|
||||
do $$
|
||||
declare
|
||||
token_result jsonb;
|
||||
status_result jsonb;
|
||||
token_id uuid;
|
||||
begin
|
||||
-- Create a token
|
||||
token_result := public.create_nonce(
|
||||
null,
|
||||
'status-check-test',
|
||||
3600
|
||||
);
|
||||
|
||||
token_id := token_result->>'id';
|
||||
|
||||
-- Get status
|
||||
status_result := public.get_nonce_status(token_id);
|
||||
|
||||
perform set_config('app.settings.status_result', status_result::text, false);
|
||||
end $$;
|
||||
|
||||
-- Check status result
|
||||
select is(
|
||||
(current_setting('app.settings.status_result', false)::jsonb)->>'exists',
|
||||
'true',
|
||||
'Token should exist'
|
||||
);
|
||||
select is(
|
||||
(current_setting('app.settings.status_result', false)::jsonb)->>'purpose',
|
||||
'status-check-test',
|
||||
'Purpose should match'
|
||||
);
|
||||
select is(
|
||||
(current_setting('app.settings.status_result', false)::jsonb)->>'is_valid',
|
||||
'true',
|
||||
'Token should be valid'
|
||||
);
|
||||
select is(
|
||||
(current_setting('app.settings.status_result', false)::jsonb)->>'verification_attempts',
|
||||
'0',
|
||||
'New token should have 0 verification attempts'
|
||||
);
|
||||
select is(
|
||||
(current_setting('app.settings.status_result', false)::jsonb)->>'used_at',
|
||||
null,
|
||||
'New token should not be used'
|
||||
);
|
||||
select is(
|
||||
(current_setting('app.settings.status_result', false)::jsonb)->>'revoked',
|
||||
'false',
|
||||
'New token should not be revoked'
|
||||
);
|
||||
|
||||
-- ==========================================
|
||||
-- Test 5: Cleanup Expired Tokens
|
||||
-- ==========================================
|
||||
|
||||
-- Test 5.1: Regular users cannot access cleanup function
|
||||
select tests.authenticate_as('token_creator');
|
||||
|
||||
select throws_ok(
|
||||
$$ select kit.cleanup_expired_nonces() $$,
|
||||
'permission denied for schema kit',
|
||||
'Regular users should not be able to clean up tokens'
|
||||
);
|
||||
|
||||
-- Test 5.2: Postgres can clean up expired tokens
|
||||
set local role postgres;
|
||||
|
||||
select
|
||||
has_function(
|
||||
'kit',
|
||||
'cleanup_expired_nonces',
|
||||
ARRAY['integer', 'boolean', 'boolean'],
|
||||
'cleanup_expired_nonces function should exist'
|
||||
);
|
||||
|
||||
do $$
|
||||
declare
|
||||
token_result jsonb;
|
||||
cleanup_result integer;
|
||||
token_count integer;
|
||||
begin
|
||||
-- Create an expired token (expiring in -10 seconds from now)
|
||||
token_result := public.create_nonce(
|
||||
null,
|
||||
'expired-token-test',
|
||||
-10 -- Negative value to create an already expired token
|
||||
);
|
||||
|
||||
-- Run cleanup
|
||||
cleanup_result := kit.cleanup_expired_nonces();
|
||||
|
||||
-- Verify the token is gone
|
||||
select count(*) into token_count from public.nonces where id = (token_result->>'id')::uuid;
|
||||
|
||||
perform set_config('app.settings.cleanup_result', cleanup_result::text, false);
|
||||
perform set_config('app.settings.token_count_after_cleanup', token_count::text, false);
|
||||
end $$;
|
||||
|
||||
-- Check cleanup results
|
||||
select cmp_ok(
|
||||
current_setting('app.settings.cleanup_result', false)::integer,
|
||||
'>=',
|
||||
1,
|
||||
'Cleanup should remove at least one expired token'
|
||||
);
|
||||
select is(
|
||||
current_setting('app.settings.token_count_after_cleanup', false)::integer,
|
||||
0,
|
||||
'Expired token should be removed after cleanup'
|
||||
);
|
||||
|
||||
-- ==========================================
|
||||
-- Test 6: Security Tests
|
||||
-- ==========================================
|
||||
|
||||
-- Test 6.1: Regular users cannot view tokens directly from the nonces table
|
||||
select tests.authenticate_as('token_creator');
|
||||
|
||||
set local role postgres;
|
||||
|
||||
do $$
|
||||
declare
|
||||
creator_id uuid;
|
||||
token_id uuid;
|
||||
begin
|
||||
-- Get the user id
|
||||
creator_id := makerkit.get_id_by_identifier('token_creator');
|
||||
|
||||
-- Create a token for this user
|
||||
token_id := (public.create_nonce(creator_id, 'security-test', 3600))->>'id';
|
||||
perform set_config('app.settings.security_test_token_id', token_id::text, false);
|
||||
end $$;
|
||||
|
||||
select tests.authenticate_as('token_creator');
|
||||
do $$
|
||||
declare
|
||||
token_id uuid;
|
||||
token_count integer;
|
||||
begin
|
||||
-- Get the token ID created by service role
|
||||
token_id := (current_setting('app.settings.security_test_token_id', false))::uuid;
|
||||
|
||||
-- Try to view token directly from nonces table
|
||||
select count(*) into token_count from public.nonces where id = token_id;
|
||||
|
||||
perform set_config('app.settings.creator_token_count', token_count::text, false);
|
||||
end $$;
|
||||
|
||||
-- Check creator can see their own token
|
||||
select is(
|
||||
current_setting('app.settings.creator_token_count', false)::integer,
|
||||
1,
|
||||
'User should be able to see their own tokens in the table'
|
||||
);
|
||||
|
||||
-- Test 6.2: Users cannot see tokens belonging to other users
|
||||
select tests.authenticate_as('token_verifier');
|
||||
do $$
|
||||
declare
|
||||
token_id uuid;
|
||||
token_count integer;
|
||||
begin
|
||||
-- Get the token ID created for the creator user
|
||||
token_id := (current_setting('app.settings.security_test_token_id', false))::uuid;
|
||||
|
||||
-- Verifier tries to view token created for creator
|
||||
select count(*) into token_count from public.nonces where id = token_id;
|
||||
|
||||
perform set_config('app.settings.verifier_token_count', token_count::text, false);
|
||||
end $$;
|
||||
|
||||
-- Check verifier cannot see creator's token
|
||||
select is(
|
||||
current_setting('app.settings.verifier_token_count', false)::integer,
|
||||
0,
|
||||
'User should not be able to see tokens belonging to other users'
|
||||
);
|
||||
|
||||
-- ==========================================
|
||||
-- Test 7: Auto-Revocation of Previous Tokens
|
||||
-- ==========================================
|
||||
|
||||
-- Test 7.1: Creating a new token should revoke previous tokens with the same purpose by default
|
||||
set local role postgres;
|
||||
|
||||
do $$
|
||||
declare
|
||||
auth_user_id uuid;
|
||||
first_token_result jsonb;
|
||||
second_token_result jsonb;
|
||||
first_token_id uuid;
|
||||
first_token_status jsonb;
|
||||
begin
|
||||
-- Get user ID
|
||||
auth_user_id := makerkit.get_id_by_identifier('token_creator');
|
||||
|
||||
-- Create first token
|
||||
first_token_result := public.create_nonce(
|
||||
auth_user_id,
|
||||
'password-reset',
|
||||
3600,
|
||||
'{"first": true}'::jsonb
|
||||
);
|
||||
|
||||
first_token_id := first_token_result->>'id';
|
||||
|
||||
-- Verify first token is valid
|
||||
first_token_status := public.get_nonce_status(first_token_id);
|
||||
|
||||
-- Create second token with same purpose
|
||||
second_token_result := public.create_nonce(
|
||||
auth_user_id,
|
||||
'password-reset',
|
||||
3600,
|
||||
'{"second": true}'::jsonb
|
||||
);
|
||||
|
||||
-- Check that first token is now revoked
|
||||
first_token_status := public.get_nonce_status(first_token_id);
|
||||
|
||||
perform set_config('app.settings.first_token_valid_before', 'true', false);
|
||||
perform set_config('app.settings.revoked_previous_count', (second_token_result->>'revoked_previous_count')::text, false);
|
||||
perform set_config('app.settings.first_token_revoked', (first_token_status->>'revoked')::text, false);
|
||||
perform set_config('app.settings.first_token_revoked_reason', (first_token_status->>'revoked_reason')::text, false);
|
||||
perform set_config('app.settings.first_token_valid_after', (first_token_status->>'is_valid')::text, false);
|
||||
end $$;
|
||||
|
||||
-- Check auto-revocation results
|
||||
select is(
|
||||
current_setting('app.settings.first_token_valid_before', false),
|
||||
'true',
|
||||
'First token should be valid initially'
|
||||
);
|
||||
|
||||
select is(
|
||||
current_setting('app.settings.revoked_previous_count', false)::integer,
|
||||
1,
|
||||
'Should report one revoked token'
|
||||
);
|
||||
|
||||
select is(
|
||||
current_setting('app.settings.first_token_revoked', false),
|
||||
'true',
|
||||
'First token should be revoked'
|
||||
);
|
||||
|
||||
select is(
|
||||
current_setting('app.settings.first_token_revoked_reason', false),
|
||||
'Superseded by new token with same purpose',
|
||||
'Revocation reason should be set'
|
||||
);
|
||||
|
||||
select is(
|
||||
current_setting('app.settings.first_token_valid_after', false),
|
||||
'false',
|
||||
'First token should be invalid'
|
||||
);
|
||||
|
||||
-- ==========================================
|
||||
-- Test 8: Maximum Verification Attempts
|
||||
-- ==========================================
|
||||
|
||||
-- Test 8.1: Token should be revoked after exceeding max verification attempts
|
||||
set local role service_role;
|
||||
|
||||
do $$
|
||||
declare
|
||||
token_result jsonb;
|
||||
verification_result jsonb;
|
||||
status_result jsonb;
|
||||
token_id uuid;
|
||||
token_text text;
|
||||
begin
|
||||
-- Create a token
|
||||
token_result := public.create_nonce(
|
||||
null,
|
||||
'max-attempts-test',
|
||||
3600
|
||||
);
|
||||
|
||||
token_id := token_result->>'id';
|
||||
token_text := token_result->>'token';
|
||||
|
||||
-- Manually set verification_attempts to just below the limit (3)
|
||||
UPDATE public.nonces
|
||||
SET verification_attempts = 3
|
||||
WHERE id = token_id;
|
||||
|
||||
-- Get status after manual update
|
||||
status_result := public.get_nonce_status(token_id);
|
||||
|
||||
-- Now perform a verification with an incorrect token - this should trigger max attempts exceeded
|
||||
verification_result := public.verify_nonce(
|
||||
'wrong-token', -- Wrong token
|
||||
'max-attempts-test', -- Correct purpose,
|
||||
NULL, -- No user id
|
||||
NULL, -- No required scopes
|
||||
3 -- Max 3 attempts
|
||||
);
|
||||
|
||||
-- The above won't increment the counter, so we need to make one more attempt with the correct token
|
||||
verification_result := public.verify_nonce(
|
||||
token_text, -- Correct token
|
||||
'max-attempts-test', -- Correct purpose,
|
||||
NULL, -- No user id
|
||||
NULL, -- No required scopes
|
||||
3 -- Max 3 attempts
|
||||
);
|
||||
|
||||
-- Check token status to verify it was revoked
|
||||
status_result := public.get_nonce_status(token_id);
|
||||
|
||||
-- Store results for assertions outside the DO block
|
||||
perform set_config('app.settings.max_attempts_verification_result', verification_result::text, false);
|
||||
perform set_config('app.settings.max_attempts_status_result', status_result::text, false);
|
||||
end $$;
|
||||
|
||||
-- Check max attempts results outside the DO block
|
||||
select is(
|
||||
(current_setting('app.settings.max_attempts_verification_result', false)::jsonb)->>'valid',
|
||||
'false',
|
||||
'Token should be invalid after exceeding max attempts'
|
||||
);
|
||||
|
||||
select is(
|
||||
(current_setting('app.settings.max_attempts_verification_result', false)::jsonb)->>'max_attempts_exceeded',
|
||||
'true',
|
||||
'Max attempts exceeded flag should be set'
|
||||
);
|
||||
|
||||
select is(
|
||||
(current_setting('app.settings.max_attempts_status_result', false)::jsonb)->>'revoked',
|
||||
'true',
|
||||
'Token should be revoked after exceeding max attempts'
|
||||
);
|
||||
|
||||
select is(
|
||||
(current_setting('app.settings.max_attempts_status_result', false)::jsonb)->>'revoked_reason',
|
||||
'Maximum verification attempts exceeded',
|
||||
'Revocation reason should indicate max attempts exceeded'
|
||||
);
|
||||
|
||||
-- Test 8.2: Setting max attempts to 0 should disable the limit
|
||||
do $$
|
||||
declare
|
||||
token_result jsonb;
|
||||
verification_result jsonb;
|
||||
status_result jsonb;
|
||||
token_id uuid;
|
||||
token_text text;
|
||||
begin
|
||||
-- Create a token
|
||||
token_result := public.create_nonce(
|
||||
null,
|
||||
'unlimited-attempts-test',
|
||||
3600
|
||||
);
|
||||
|
||||
token_id := token_result->>'id';
|
||||
token_text := token_result->>'token';
|
||||
|
||||
-- Manually set verification_attempts to a high number
|
||||
UPDATE public.nonces
|
||||
SET verification_attempts = 10
|
||||
WHERE id = token_id;
|
||||
|
||||
-- Get status after manual update
|
||||
status_result := public.get_nonce_status(token_id);
|
||||
|
||||
-- Now perform a verification with the correct token and unlimited attempts
|
||||
verification_result := public.verify_nonce(
|
||||
token_text, -- Correct token
|
||||
'unlimited-attempts-test', -- Correct purpose,
|
||||
NULL, -- No user id
|
||||
NULL, -- No required scopes
|
||||
0 -- Unlimited attempts (disabled)
|
||||
);
|
||||
|
||||
-- Check token status to verify it was not revoked
|
||||
status_result := public.get_nonce_status(token_id);
|
||||
|
||||
-- Store results for assertions outside the DO block
|
||||
perform set_config('app.settings.unlimited_attempts_status', status_result::text, false);
|
||||
end $$;
|
||||
|
||||
-- Check unlimited attempts results outside the DO block
|
||||
select is(
|
||||
(current_setting('app.settings.unlimited_attempts_status', false)::jsonb)->>'revoked',
|
||||
'false',
|
||||
'Token should not be revoked when max attempts is disabled'
|
||||
);
|
||||
|
||||
select cmp_ok(
|
||||
(current_setting('app.settings.unlimited_attempts_status', false)::jsonb)->>'verification_attempts',
|
||||
'>=',
|
||||
'10',
|
||||
'Token should record at least 10 verification attempts'
|
||||
);
|
||||
|
||||
-- Finish tests
|
||||
select * from finish();
|
||||
|
||||
rollback;
|
||||
|
||||
@@ -44,6 +44,16 @@ select
|
||||
$$, row ('owner'::varchar),
|
||||
'The primary owner should have the owner role for the team account');
|
||||
|
||||
select is(
|
||||
public.is_account_owner((select
|
||||
id
|
||||
from public.accounts
|
||||
where
|
||||
slug = 'test')),
|
||||
true,
|
||||
'The current user should be the owner of the team account'
|
||||
);
|
||||
|
||||
-- Should be able to see the team account
|
||||
select
|
||||
isnt_empty($$
|
||||
@@ -58,6 +68,16 @@ select
|
||||
select
|
||||
tests.authenticate_as('test2');
|
||||
|
||||
select is(
|
||||
public.is_account_owner((select
|
||||
id
|
||||
from public.accounts
|
||||
where
|
||||
slug = 'test')),
|
||||
false,
|
||||
'The current user should not be the owner of the team account'
|
||||
);
|
||||
|
||||
select
|
||||
is_empty($$
|
||||
select
|
||||
|
||||
Reference in New Issue
Block a user