feat(auth): request secure messenger start sessions

This commit is contained in:
Ruslan Bakiev
2026-04-03 18:12:17 +07:00
parent 93b6c51625
commit 6cadd5160c
6 changed files with 190 additions and 65 deletions

View File

@@ -1,21 +1,10 @@
function toBase64Url(value: string) { export function buildMessengerBotStartUrl(baseUrl: string, startToken: string) {
if (typeof Buffer !== 'undefined') { const normalizedToken = startToken.trim();
return Buffer.from(value, 'utf8').toString('base64url'); if (!baseUrl || !normalizedToken) {
}
return btoa(value)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
export function buildMessengerBotStartUrl(baseUrl: string, email: string) {
const normalizedEmail = email.trim().toLowerCase();
if (!baseUrl || !normalizedEmail) {
return ''; return '';
} }
const payload = encodeURIComponent(toBase64Url(`login:${normalizedEmail}`)); const payload = encodeURIComponent(normalizedToken);
const separator = baseUrl.includes('?') ? '&' : '?'; const separator = baseUrl.includes('?') ? '&' : '?';
return `${baseUrl}${separator}start=${payload}`; return `${baseUrl}${separator}start=${payload}`;
} }

View File

@@ -0,0 +1,49 @@
import { buildMessengerBotStartUrl } from '~/composables/useMessengerBotLink';
type MessengerChannel = 'TELEGRAM' | 'MAX';
type MessengerStartResponse = {
ok: true;
startToken: string;
expiresAt: string;
mode: 'login' | 'connect';
};
type MessengerStartInput = {
channel: MessengerChannel;
baseUrl: string;
email?: string;
};
export function useMessengerStart() {
const pendingChannel = ref<MessengerChannel | null>(null);
async function openMessengerBot({ channel, baseUrl, email }: MessengerStartInput) {
pendingChannel.value = channel;
const payloadPromise = $fetch<MessengerStartResponse>('/api/auth/messenger-start', {
method: 'POST',
body: {
channel,
email,
},
});
payloadPromise.finally(() => {
pendingChannel.value = null;
});
const payload = await payloadPromise;
const startUrl = buildMessengerBotStartUrl(baseUrl, payload.startToken);
if (import.meta.client) {
window.open(startUrl, '_blank', 'noopener,noreferrer');
}
return payload;
}
return {
pendingChannel,
openMessengerBot,
};
}

View File

@@ -5,7 +5,7 @@ import {
RequestLoginCodeDocument, RequestLoginCodeDocument,
VerifyLoginCodeDocument, VerifyLoginCodeDocument,
} from '~/composables/graphql/generated'; } from '~/composables/graphql/generated';
import { buildMessengerBotStartUrl } from '~/composables/useMessengerBotLink'; import { useMessengerStart } from '~/composables/useMessengerStart';
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const route = useRoute(); const route = useRoute();
@@ -29,6 +29,7 @@ const lastRequestedEmail = ref('');
const requestCodeMutation = useMutation(RequestLoginCodeDocument, { throws: 'never' }); const requestCodeMutation = useMutation(RequestLoginCodeDocument, { throws: 'never' });
const verifyCodeMutation = useMutation(VerifyLoginCodeDocument, { throws: 'never' }); const verifyCodeMutation = useMutation(VerifyLoginCodeDocument, { throws: 'never' });
const consumeLoginTokenMutation = useMutation(ConsumeLoginTokenDocument, { throws: 'never' }); const consumeLoginTokenMutation = useMutation(ConsumeLoginTokenDocument, { throws: 'never' });
const { openMessengerBot, pendingChannel } = useMessengerStart();
const telegramBotUrl = computed(() => config.public.telegramBotUrl || ''); const telegramBotUrl = computed(() => config.public.telegramBotUrl || '');
const maxBotUrl = computed(() => config.public.maxBotUrl || ''); const maxBotUrl = computed(() => config.public.maxBotUrl || '');
@@ -36,13 +37,6 @@ const maxBotUrl = computed(() => config.public.maxBotUrl || '');
const normalizedEmail = computed(() => email.value.trim().toLowerCase()); const normalizedEmail = computed(() => email.value.trim().toLowerCase());
const isEmailReady = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail.value)); const isEmailReady = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail.value));
const telegramLoginUrl = computed(() =>
isEmailReady.value ? buildMessengerBotStartUrl(telegramBotUrl.value, normalizedEmail.value) : '',
);
const maxLoginUrl = computed(() =>
isEmailReady.value ? buildMessengerBotStartUrl(maxBotUrl.value, normalizedEmail.value) : '',
);
async function finalizeSession(accessToken: string) { async function finalizeSession(accessToken: string) {
authCookie.value = accessToken; authCookie.value = accessToken;
} }
@@ -167,6 +161,28 @@ async function consumeLoginToken(loginToken: string) {
await navigateAfterLogin(payload.user); await navigateAfterLogin(payload.user);
} }
async function startMessengerLogin(channel: 'TELEGRAM' | 'MAX') {
if (!isEmailReady.value) {
feedback.value = 'Введите корректный email.';
feedbackTone.value = 'error';
return;
}
const baseUrl = channel === 'TELEGRAM' ? telegramBotUrl.value : maxBotUrl.value;
if (!baseUrl) {
feedback.value = 'Ссылка на бота пока не настроена.';
feedbackTone.value = 'error';
return;
}
feedback.value = '';
await openMessengerBot({
channel,
baseUrl,
email: normalizedEmail.value,
});
}
function scheduleAutoRequest() { function scheduleAutoRequest() {
clearAutoRequestTimer(); clearAutoRequestTimer();
@@ -242,24 +258,22 @@ onBeforeUnmount(() => {
</fieldset> </fieldset>
<div class="grid gap-2 sm:grid-cols-2"> <div class="grid gap-2 sm:grid-cols-2">
<a <button
:href="telegramLoginUrl || undefined"
target="_blank"
rel="noopener noreferrer"
class="btn btn-secondary" class="btn btn-secondary"
:class="{ 'btn-disabled pointer-events-none': !telegramLoginUrl }" :class="{ 'btn-disabled pointer-events-none': !telegramBotUrl || !isEmailReady }"
:disabled="pendingChannel === 'TELEGRAM' || !telegramBotUrl || !isEmailReady"
@click="startMessengerLogin('TELEGRAM')"
> >
Войти через Telegram {{ pendingChannel === 'TELEGRAM' ? 'Открываем Telegram…' : 'Войти через Telegram' }}
</a> </button>
<a <button
:href="maxLoginUrl || undefined"
target="_blank"
rel="noopener noreferrer"
class="btn btn-accent" class="btn btn-accent"
:class="{ 'btn-disabled pointer-events-none': !maxLoginUrl }" :class="{ 'btn-disabled pointer-events-none': !maxBotUrl || !isEmailReady }"
:disabled="pendingChannel === 'MAX' || !maxBotUrl || !isEmailReady"
@click="startMessengerLogin('MAX')"
> >
Войти через Max {{ pendingChannel === 'MAX' ? 'Открываем Max…' : 'Войти через Max' }}
</a> </button>
</div> </div>
<p v-if="requestCodeMutation.loading.value" class="text-sm text-base-content/70"> <p v-if="requestCodeMutation.loading.value" class="text-sm text-base-content/70">

View File

@@ -6,12 +6,13 @@ import {
MyNotificationHistoryDocument, MyNotificationHistoryDocument,
SendTestMessengerMessageDocument, SendTestMessengerMessageDocument,
} from '~/composables/graphql/generated'; } from '~/composables/graphql/generated';
import { buildMessengerBotStartUrl } from '~/composables/useMessengerBotLink'; import { useMessengerStart } from '~/composables/useMessengerStart';
const selectedChannel = ref<'TELEGRAM' | 'MAX'>('TELEGRAM'); const selectedChannel = ref<'TELEGRAM' | 'MAX'>('TELEGRAM');
const customMessage = ref('Тест канала уведомлений Fregat'); const customMessage = ref('Тест канала уведомлений Fregat');
const feedback = ref(''); const feedback = ref('');
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const { openMessengerBot, pendingChannel } = useMessengerStart();
const meQuery = useQuery(MeDocument); const meQuery = useQuery(MeDocument);
const connectionsQuery = useQuery(MyMessengerConnectionsDocument); const connectionsQuery = useQuery(MyMessengerConnectionsDocument);
@@ -56,12 +57,24 @@ function buildBotConnectUrl(baseUrl: string) {
return ''; return '';
} }
return buildMessengerBotStartUrl(baseUrl, email); return baseUrl;
} }
const telegramConnectUrl = computed(() => buildBotConnectUrl(config.public.telegramBotUrl || '')); const telegramConnectUrl = computed(() => buildBotConnectUrl(config.public.telegramBotUrl || ''));
const maxConnectUrl = computed(() => buildBotConnectUrl(config.public.maxBotUrl || '')); const maxConnectUrl = computed(() => buildBotConnectUrl(config.public.maxBotUrl || ''));
async function connectMessenger(channel: 'TELEGRAM' | 'MAX') {
const baseUrl = channel === 'TELEGRAM' ? telegramConnectUrl.value : maxConnectUrl.value;
if (!baseUrl) {
return;
}
await openMessengerBot({
channel,
baseUrl,
});
}
async function sendTest() { async function sendTest() {
feedback.value = ''; feedback.value = '';
if (!activeConnection.value) { if (!activeConnection.value) {
@@ -114,15 +127,20 @@ async function sendTest() {
</p> </p>
</div> </div>
<a <button
:href="telegramConnectUrl || undefined"
target="_blank"
rel="noopener noreferrer"
class="btn btn-secondary" class="btn btn-secondary"
:class="{ 'btn-disabled pointer-events-none': !telegramConnectUrl }" :class="{ 'btn-disabled pointer-events-none': !telegramConnectUrl }"
:disabled="pendingChannel === 'TELEGRAM' || !telegramConnectUrl"
@click="connectMessenger('TELEGRAM')"
> >
{{ telegramConnection ? 'Переподключить Telegram' : 'Подключить Telegram' }} {{
</a> pendingChannel === 'TELEGRAM'
? 'Открываем Telegram…'
: telegramConnection
? 'Переподключить Telegram'
: 'Подключить Telegram'
}}
</button>
</div> </div>
</div> </div>
@@ -139,15 +157,20 @@ async function sendTest() {
</p> </p>
</div> </div>
<a <button
:href="maxConnectUrl || undefined"
target="_blank"
rel="noopener noreferrer"
class="btn btn-accent" class="btn btn-accent"
:class="{ 'btn-disabled pointer-events-none': !maxConnectUrl }" :class="{ 'btn-disabled pointer-events-none': !maxConnectUrl }"
:disabled="pendingChannel === 'MAX' || !maxConnectUrl"
@click="connectMessenger('MAX')"
> >
{{ maxConnection ? 'Переподключить Max' : 'Подключить Max' }} {{
</a> pendingChannel === 'MAX'
? 'Открываем Max…'
: maxConnection
? 'Переподключить Max'
: 'Подключить Max'
}}
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -4,7 +4,7 @@ import {
MeDocument, MeDocument,
MyMessengerConnectionsDocument, MyMessengerConnectionsDocument,
} from '~/composables/graphql/generated'; } from '~/composables/graphql/generated';
import { buildMessengerBotStartUrl } from '~/composables/useMessengerBotLink'; import { useMessengerStart } from '~/composables/useMessengerStart';
type MessengerItem = { type MessengerItem = {
type: 'TELEGRAM' | 'MAX'; type: 'TELEGRAM' | 'MAX';
@@ -15,6 +15,7 @@ type MessengerItem = {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const meQuery = useQuery(MeDocument); const meQuery = useQuery(MeDocument);
const connectionsQuery = useQuery(MyMessengerConnectionsDocument); const connectionsQuery = useQuery(MyMessengerConnectionsDocument);
const { openMessengerBot, pendingChannel } = useMessengerStart();
const telegramConnection = computed(() => const telegramConnection = computed(() =>
connectionsQuery.result.value?.myMessengerConnections?.find( connectionsQuery.result.value?.myMessengerConnections?.find(
@@ -34,11 +35,23 @@ function buildBotConnectUrl(baseUrl: string) {
return ''; return '';
} }
return buildMessengerBotStartUrl(baseUrl, accountEmail); return baseUrl;
} }
const telegramConnectUrl = computed(() => buildBotConnectUrl(config.public.telegramBotUrl || '')); const telegramConnectUrl = computed(() => buildBotConnectUrl(config.public.telegramBotUrl || ''));
const maxConnectUrl = computed(() => buildBotConnectUrl(config.public.maxBotUrl || '')); const maxConnectUrl = computed(() => buildBotConnectUrl(config.public.maxBotUrl || ''));
async function connectMessenger(channel: 'TELEGRAM' | 'MAX') {
const baseUrl = channel === 'TELEGRAM' ? telegramConnectUrl.value : maxConnectUrl.value;
if (!baseUrl) {
return;
}
await openMessengerBot({
channel,
baseUrl,
});
}
</script> </script>
<template> <template>
@@ -57,15 +70,20 @@ const maxConnectUrl = computed(() => buildBotConnectUrl(config.public.maxBotUrl
<p class="text-sm opacity-80"> <p class="text-sm opacity-80">
{{ telegramConnection ? `Подключен: ${telegramConnection.channelId}` : 'Не подключен' }} {{ telegramConnection ? `Подключен: ${telegramConnection.channelId}` : 'Не подключен' }}
</p> </p>
<a <button
:href="telegramConnectUrl || undefined"
target="_blank"
rel="noopener noreferrer"
class="btn btn-secondary mt-3 w-full" class="btn btn-secondary mt-3 w-full"
:class="{ 'btn-disabled pointer-events-none': !telegramConnectUrl }" :class="{ 'btn-disabled pointer-events-none': !telegramConnectUrl }"
:disabled="pendingChannel === 'TELEGRAM' || !telegramConnectUrl"
@click="connectMessenger('TELEGRAM')"
> >
{{ telegramConnection ? 'Переподключить Telegram' : 'Подключить Telegram' }} {{
</a> pendingChannel === 'TELEGRAM'
? 'Открываем Telegram…'
: telegramConnection
? 'Переподключить Telegram'
: 'Подключить Telegram'
}}
</button>
</div> </div>
<div class="rounded-2xl bg-[#f8fbf9] p-4 transition hover:shadow-md"> <div class="rounded-2xl bg-[#f8fbf9] p-4 transition hover:shadow-md">
@@ -73,15 +91,20 @@ const maxConnectUrl = computed(() => buildBotConnectUrl(config.public.maxBotUrl
<p class="text-sm opacity-80"> <p class="text-sm opacity-80">
{{ maxConnection ? `Подключен: ${maxConnection.channelId}` : 'Не подключен' }} {{ maxConnection ? `Подключен: ${maxConnection.channelId}` : 'Не подключен' }}
</p> </p>
<a <button
:href="maxConnectUrl || undefined"
target="_blank"
rel="noopener noreferrer"
class="btn btn-accent mt-3 w-full" class="btn btn-accent mt-3 w-full"
:class="{ 'btn-disabled pointer-events-none': !maxConnectUrl }" :class="{ 'btn-disabled pointer-events-none': !maxConnectUrl }"
:disabled="pendingChannel === 'MAX' || !maxConnectUrl"
@click="connectMessenger('MAX')"
> >
{{ maxConnection ? 'Переподключить Max' : 'Подключить Max' }} {{
</a> pendingChannel === 'MAX'
? 'Открываем Max…'
: maxConnection
? 'Переподключить Max'
: 'Подключить Max'
}}
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,27 @@
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event);
const backendUrl = new URL(config.backendGraphqlUrl);
const endpoint = `${backendUrl.origin}/auth/messenger-start`;
const body = await readBody(event);
const cookie = getHeader(event, 'cookie');
const authorization = getHeader(event, 'authorization');
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(cookie ? { cookie } : {}),
...(authorization ? { authorization } : {}),
},
body: JSON.stringify(body),
});
setResponseStatus(event, response.status);
const contentType = response.headers.get('content-type');
if (contentType) {
setHeader(event, 'content-type', contentType);
}
return await response.json();
});