494 lines
16 KiB
PL/PgSQL
494 lines
16 KiB
PL/PgSQL
-- Подключение расширения 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;
|