-- Подключение расширения pgcrypto для генерации UUID. CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE SCHEMA IF NOT EXISTS binding; CREATE SCHEMA IF NOT EXISTS oauth; CREATE TABLE IF NOT EXISTS binding.portals ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- member_id - уникальный идентификатор портала из Битрикса. member_id text NOT NULL UNIQUE, -- domain - домен портала, например: example.bitrix24.ru. domain text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS binding.tokens ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), portal_id bigint NOT NULL REFERENCES binding.portals(id) ON DELETE CASCADE, bitrix_user_id bigint NOT NULL, -- Хеш токена, генерируется сервером. token_hash bytea NOT NULL UNIQUE, expires_at timestamptz NOT NULL, consumed_at timestamptz, revoked_at timestamptz, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS binding_tokens_owner_idx ON binding.tokens (portal_id, bitrix_user_id, created_at DESC); CREATE TABLE IF NOT EXISTS binding.user_bindings ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), portal_id bigint NOT NULL REFERENCES binding.portals(id) ON DELETE CASCADE, bitrix_user_id bigint NOT NULL, telegram_user_id bigint NOT NULL, telegram_chat_id bigint NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (portal_id, bitrix_user_id), UNIQUE (portal_id, telegram_user_id) ); CREATE TABLE IF NOT EXISTS oauth.user_credentials ( portal_id bigint NOT NULL REFERENCES binding.portals(id) ON DELETE CASCADE, bitrix_user_id bigint NOT NULL, access_token bytea NOT NULL, refresh_token bytea NOT NULL, expires_at timestamptz NOT NULL, -- Двойной механизм защиты от гонок данных при обновлении токенов. version bigint NOT NULL DEFAULT 1, refresh_locked_until timestamptz, updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (portal_id, bitrix_user_id) ); CREATE OR REPLACE FUNCTION binding.issue_v1( p_member_id text, p_domain text, p_bitrix_user_id bigint, p_token_hash bytea, p_token_expires_at timestamptz, p_access_token bytea, p_refresh_token bytea, p_oauth_expires_at timestamptz ) RETURNS TABLE(token_id uuid, expires_at timestamptz) LANGUAGE plpgsql -- Задаем SECURITY DEFINER, чтобы функция выполнялась с правами владельца -- схемы binding. SECURITY DEFINER -- Устанавливаем search_path в pg_catalog, чтобы нельзя было подменить -- функции в схеме binding или oauth. SET search_path = pg_catalog AS $function$ #variable_conflict error DECLARE v_portal_id bigint; v_token_id uuid; BEGIN -- Добавляем данные о портале, если его ещё нет, -- или обновляем домен, если портал уже существует. INSERT INTO binding.portals(member_id, domain) VALUES (p_member_id, lower(p_domain)) ON CONFLICT (member_id) DO UPDATE SET domain = EXCLUDED.domain, updated_at = now() RETURNING id INTO v_portal_id; -- Сохраняем OAuth-данные пользователя, если их ещё нет, -- или обновляем их, если они уже существуют. INSERT INTO oauth.user_credentials( portal_id, bitrix_user_id, access_token, refresh_token, expires_at ) VALUES ( v_portal_id, p_bitrix_user_id, p_access_token, p_refresh_token, p_oauth_expires_at ) ON CONFLICT (portal_id, bitrix_user_id) DO UPDATE SET access_token = EXCLUDED.access_token, refresh_token = EXCLUDED.refresh_token, expires_at = EXCLUDED.expires_at, version = oauth.user_credentials.version + 1, refresh_locked_until = NULL, updated_at = now(); -- Отзываем все предыдущие неиспользованные токены пользователя. UPDATE binding.tokens SET revoked_at = now() WHERE portal_id = v_portal_id AND bitrix_user_id = p_bitrix_user_id AND consumed_at IS NULL AND revoked_at IS NULL; -- Выпускаем новый токен привязки. INSERT INTO binding.tokens( portal_id, bitrix_user_id, token_hash, expires_at ) VALUES ( v_portal_id, p_bitrix_user_id, p_token_hash, p_token_expires_at ) RETURNING id INTO v_token_id; -- Возвращаем идентификатор токена и срок его действия. RETURN QUERY SELECT v_token_id, p_token_expires_at; END $function$; COMMENT ON FUNCTION binding.issue_v1( text, text, bigint, bytea, timestamptz, bytea, bytea, timestamptz ) IS $doc$ Сохраняет OAuth-данные пользователя и выпускает токен привязки. Гарантии: - предыдущие неиспользованные токены отзываются; - при обновлении OAuth-данных увеличивается их версия. Возвращает: - token_id - идентификатор токена; - expires_at - срок действия токена. $doc$; CREATE OR REPLACE FUNCTION binding.consume_v1( p_token_hash bytea, p_telegram_user_id bigint, p_telegram_chat_id bigint ) RETURNS TABLE( member_id text, domain text, bitrix_user_id bigint, telegram_user_id bigint ) LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog AS $function$ #variable_conflict error DECLARE v_portal_id bigint; v_bitrix_user_id bigint; BEGIN -- UPDATE не позволит двум запросам погасить один токен. UPDATE binding.tokens AS token SET consumed_at = now() WHERE token.token_hash = p_token_hash AND token.consumed_at IS NULL AND token.revoked_at IS NULL AND token.expires_at > now() RETURNING token.portal_id, token.bitrix_user_id INTO v_portal_id, v_bitrix_user_id; -- Если UPDATE не вернул ни одной строки, значит токен недействителен. IF NOT FOUND THEN RETURN; END IF; -- Удаляем все привязки к Telegram для данного портала и пользователя Битрикса, -- кроме той, которая соответствует текущему пользователю Битрикса. DELETE FROM binding.user_bindings AS user_binding WHERE user_binding.portal_id = v_portal_id AND user_binding.telegram_user_id = p_telegram_user_id AND user_binding.bitrix_user_id <> v_bitrix_user_id; -- Добавляем или обновляем привязку к Telegram для данного портала -- и пользователя Битрикса. INSERT INTO binding.user_bindings( portal_id, bitrix_user_id, telegram_user_id, telegram_chat_id ) VALUES ( v_portal_id, v_bitrix_user_id, p_telegram_user_id, p_telegram_chat_id ) ON CONFLICT ON CONSTRAINT user_bindings_pkey DO UPDATE SET telegram_user_id = EXCLUDED.telegram_user_id, telegram_chat_id = EXCLUDED.telegram_chat_id, updated_at = now(); RETURN QUERY SELECT portal.member_id, portal.domain, v_bitrix_user_id, p_telegram_user_id FROM binding.portals AS portal WHERE portal.id = v_portal_id; END $function$; COMMENT ON FUNCTION binding.consume_v1( bytea, bigint, bigint ) IS $doc$ Погашает токен привязки и сохраняет привязку к Telegram. Гарантии: - токен погашается только один раз; - если токен недействителен, функция возвращает пустой результат; - если токен действителен, функция возвращает данные портала и пользователя Битрикса, а также сохраняет привязку к Telegram; - если пользователь Битрикса уже был привязан к другому пользователю Telegram, старая привязка удаляется. Возвращает: - member_id - идентификатор портала; - domain - домен портала; - bitrix_user_id - идентификатор пользователя Битрикса; - telegram_user_id - идентификатор пользователя Telegram. $doc$; CREATE OR REPLACE FUNCTION binding.find_by_telegram_v1( p_telegram_user_id bigint, p_member_id text DEFAULT NULL ) RETURNS TABLE( member_id text, domain text, bitrix_user_id bigint, telegram_user_id bigint ) LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog AS $function$ SELECT portal.member_id, portal.domain, user_binding.bitrix_user_id, user_binding.telegram_user_id FROM binding.user_bindings AS user_binding JOIN binding.portals AS portal ON portal.id = user_binding.portal_id WHERE user_binding.telegram_user_id = p_telegram_user_id AND (p_member_id IS NULL OR portal.member_id = p_member_id) ORDER BY user_binding.updated_at DESC LIMIT 1 $function$; COMMENT ON FUNCTION binding.find_by_telegram_v1( bigint, text ) IS $doc$ Находит привязку к Telegram по идентификатору пользователя Telegram. Возвращает: - member_id - идентификатор портала; - domain - домен портала; - bitrix_user_id - идентификатор пользователя Битрикса; - telegram_user_id - идентификатор пользователя Telegram. Если p_member_id не NULL, то поиск ограничивается указанным порталом. $doc$; CREATE OR REPLACE FUNCTION oauth.get_credentials_v1( p_member_id text, p_bitrix_user_id bigint ) RETURNS TABLE( member_id text, domain text, bitrix_user_id bigint, access_token bytea, refresh_token bytea, expires_at timestamptz, version bigint ) LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog AS $function$ SELECT portal.member_id, portal.domain, credentials.bitrix_user_id, credentials.access_token, credentials.refresh_token, credentials.expires_at, credentials.version FROM oauth.user_credentials AS credentials JOIN binding.portals AS portal ON portal.id = credentials.portal_id WHERE portal.member_id = p_member_id AND credentials.bitrix_user_id = p_bitrix_user_id $function$; COMMENT ON FUNCTION oauth.get_credentials_v1( text, bigint ) IS $doc$ Находит OAuth-данные пользователя по идентификатору портала и идентификатору пользователя Битрикса. Возвращает: - member_id - идентификатор портала; - domain - домен портала; - bitrix_user_id - идентификатор пользователя Битрикса; - access_token - токен доступа; - refresh_token - токен обновления; - expires_at - срок действия токена доступа; - version - версия данных. $doc$; CREATE OR REPLACE FUNCTION oauth.claim_refresh_v1( p_member_id text, p_bitrix_user_id bigint, p_version bigint ) RETURNS boolean LANGUAGE sql VOLATILE SECURITY DEFINER SET search_path = pg_catalog AS $function$ -- Создаем временную таблицу claimed, -- которая будет содержать результат обновления (CTE). WITH claimed AS ( UPDATE oauth.user_credentials AS credentials SET refresh_locked_until = now() + interval '30 seconds' FROM binding.portals AS portal WHERE credentials.portal_id = portal.id AND portal.member_id = p_member_id AND credentials.bitrix_user_id = p_bitrix_user_id AND credentials.version = p_version AND ( credentials.refresh_locked_until IS NULL OR credentials.refresh_locked_until < now() ) RETURNING 1 ) SELECT EXISTS(SELECT 1 FROM claimed) $function$; COMMENT ON FUNCTION oauth.claim_refresh_v1( text, bigint, bigint ) IS $doc$ Пытается захватить токен обновления для пользователя. Возвращает: - true, если захват успешен; - false, если захват не удался. $doc$; CREATE OR REPLACE FUNCTION oauth.finish_refresh_v1( p_member_id text, p_bitrix_user_id bigint, p_version bigint, p_access_token bytea, p_refresh_token bytea, p_expires_at timestamptz ) RETURNS boolean LANGUAGE sql VOLATILE SECURITY DEFINER SET search_path = pg_catalog AS $function$ WITH updated AS ( UPDATE oauth.user_credentials AS credentials SET access_token = p_access_token, refresh_token = p_refresh_token, expires_at = p_expires_at, version = credentials.version + 1, refresh_locked_until = NULL, updated_at = now() FROM binding.portals AS portal WHERE credentials.portal_id = portal.id AND portal.member_id = p_member_id AND credentials.bitrix_user_id = p_bitrix_user_id AND credentials.version = p_version RETURNING 1 ) SELECT EXISTS(SELECT 1 FROM updated) $function$; COMMENT ON FUNCTION oauth.finish_refresh_v1( text, bigint, bigint, bytea, bytea, timestamptz ) IS $doc$ Завершает процесс обновления токена для пользователя. Возвращает: - true, если обновление успешно завершено; - false, если обновление не удалось. $doc$; CREATE OR REPLACE FUNCTION oauth.release_refresh_v1( p_member_id text, p_bitrix_user_id bigint, p_version bigint ) RETURNS void LANGUAGE sql VOLATILE SECURITY DEFINER SET search_path = pg_catalog AS $function$ UPDATE oauth.user_credentials AS credentials SET refresh_locked_until = NULL FROM binding.portals AS portal WHERE credentials.portal_id = portal.id AND portal.member_id = p_member_id AND credentials.bitrix_user_id = p_bitrix_user_id AND credentials.version = p_version $function$; COMMENT ON FUNCTION oauth.release_refresh_v1( text, bigint, bigint ) IS $doc$ Освобождает токен обновления для пользователя. Возвращает: - void. $doc$; -- Отзываем все права у PUBLIC. REVOKE ALL ON ALL TABLES IN SCHEMA binding, oauth FROM PUBLIC; REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA binding, oauth FROM PUBLIC; -- Даем права на использование схемы и выполнение функций ролям site_role -- и bot_role. GRANT USAGE ON SCHEMA binding TO site_role, bot_role; GRANT USAGE ON SCHEMA oauth TO bot_role; -- Даем права на выполнение функций ролям site_role и bot_role. GRANT EXECUTE ON FUNCTION binding.issue_v1( text, text, bigint, bytea, timestamptz, bytea, bytea, timestamptz ) TO site_role; GRANT EXECUTE ON FUNCTION binding.consume_v1(bytea, bigint, bigint) TO bot_role; GRANT EXECUTE ON FUNCTION binding.find_by_telegram_v1(bigint, text) TO bot_role; GRANT EXECUTE ON FUNCTION oauth.get_credentials_v1(text, bigint) TO bot_role; GRANT EXECUTE ON FUNCTION oauth.claim_refresh_v1(text, bigint, bigint) TO bot_role; GRANT EXECUTE ON FUNCTION oauth.finish_refresh_v1( text, bigint, bigint, bytea, bytea, timestamptz ) TO bot_role; GRANT EXECUTE ON FUNCTION oauth.release_refresh_v1(text, bigint, bigint) TO bot_role;