From 6c5839d6ee51cabfb551efc2450503b980efde46 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:12:05 +0700 Subject: [PATCH] feat(auth): secure messenger start token flow --- src/auth.js | 54 ++++++++++++++++++++++++- src/resolvers.js | 29 +++++++++++++ src/server.js | 103 +++++++++++++++++++++++++++++++++-------------- 3 files changed, 154 insertions(+), 32 deletions(-) diff --git a/src/auth.js b/src/auth.js index 3fd374f..236d9c7 100644 --- a/src/auth.js +++ b/src/auth.js @@ -5,10 +5,12 @@ const AUTH_TOKEN_SECRET = process.env.AUTH_TOKEN_SECRET || 'fregat-auth-dev-secr const AUTH_TOKEN_TTL_SECONDS = Number(process.env.AUTH_TOKEN_TTL_SECONDS ?? 60 * 60 * 24 * 30); const AUTH_LOGIN_CHALLENGE_TTL_SECONDS = Number(process.env.AUTH_LOGIN_CHALLENGE_TTL_SECONDS ?? 10 * 60); const AUTH_LOGIN_LINK_TTL_SECONDS = Number(process.env.AUTH_LOGIN_LINK_TTL_SECONDS ?? 5 * 60); +const AUTH_MESSENGER_START_TTL_SECONDS = Number(process.env.AUTH_MESSENGER_START_TTL_SECONDS ?? 10 * 60); const AUTH_STATIC_CODE = process.env.AUTH_STATIC_CODE || '123456'; const activeChallenges = new Map(); const activeLoginTokens = new Map(); +const activeMessengerStartSessions = new Map(); function sign(data) { return crypto.createHmac('sha256', AUTH_TOKEN_SECRET).update(data).digest('base64url'); @@ -53,6 +55,15 @@ function purgeExpiredLoginTokens() { } } +function purgeExpiredMessengerStartSessions() { + const now = Date.now(); + for (const [startToken, payload] of activeMessengerStartSessions.entries()) { + if (payload.expiresAt <= now) { + activeMessengerStartSessions.delete(startToken); + } + } +} + function maskEmail(email) { const [local, domain] = email.split('@'); if (!domain) { @@ -168,13 +179,53 @@ export function verifyLoginChallengeCode({ challengeToken, code }) { }; } -export function issueTemporaryLoginToken(userId) { +export function createMessengerStartSession({ channel, email, userId }) { + purgeExpiredMessengerStartSessions(); + + const startToken = crypto.randomBytes(24).toString('base64url'); + const expiresAt = Date.now() + AUTH_MESSENGER_START_TTL_SECONDS * 1000; + activeMessengerStartSessions.set(startToken, { + channel, + email, + userId, + expiresAt, + }); + + return { + startToken, + expiresAt: new Date(expiresAt), + }; +} + +export function consumeMessengerStartSession(startToken) { + purgeExpiredMessengerStartSessions(); + + const payload = activeMessengerStartSessions.get(startToken); + if (!payload) { + throw new Error('Messenger start token is invalid or expired.'); + } + + activeMessengerStartSessions.delete(startToken); + return { + channel: payload.channel, + email: payload.email, + userId: payload.userId, + }; +} + +export function hasMessengerStartSession(startToken) { + purgeExpiredMessengerStartSessions(); + return activeMessengerStartSessions.has(startToken); +} + +export function issueTemporaryLoginToken({ userId, messengerConnection = null }) { purgeExpiredLoginTokens(); const loginToken = crypto.randomBytes(24).toString('hex'); const expiresAt = Date.now() + AUTH_LOGIN_LINK_TTL_SECONDS * 1000; activeLoginTokens.set(loginToken, { userId, + messengerConnection, expiresAt, }); @@ -195,6 +246,7 @@ export function consumeTemporaryLoginToken(loginToken) { activeLoginTokens.delete(loginToken); return { userId: payload.userId, + messengerConnection: payload.messengerConnection, }; } diff --git a/src/resolvers.js b/src/resolvers.js index 2715fbe..b1cf767 100644 --- a/src/resolvers.js +++ b/src/resolvers.js @@ -504,6 +504,35 @@ export const resolvers = { throw new Error('User for this login token was not found.'); } + if (login.messengerConnection) { + await context.prisma.messengerConnection.updateMany({ + where: { + userId: user.id, + type: login.messengerConnection.type, + isActive: true, + NOT: { channelId: login.messengerConnection.channelId }, + }, + data: { isActive: false }, + }); + + await context.prisma.messengerConnection.upsert({ + where: { + userId_type_channelId: { + userId: user.id, + type: login.messengerConnection.type, + channelId: login.messengerConnection.channelId, + }, + }, + update: { isActive: true }, + create: { + userId: user.id, + type: login.messengerConnection.type, + channelId: login.messengerConnection.channelId, + isActive: true, + }, + }); + } + const session = issueAccessToken(user.id); return { accessToken: session.accessToken, diff --git a/src/server.js b/src/server.js index 72aeb7f..bf90470 100644 --- a/src/server.js +++ b/src/server.js @@ -8,7 +8,14 @@ import express from 'express'; import { ApolloServer } from '@apollo/server'; import { expressMiddleware } from '@as-integrations/express5'; -import { issueTemporaryLoginToken } from './auth.js'; +import { + createMessengerStartSession, + consumeMessengerStartSession, + extractAuthTokenFromRequest, + hasMessengerStartSession, + issueTemporaryLoginToken, + verifyAccessToken, +} from './auth.js'; import { buildContext } from './context.js'; import { sendMessengerMessage } from './messenger.js'; import { prisma } from './prisma-client.js'; @@ -73,6 +80,49 @@ async function resolveUserForMessenger({ userId, email }) { }); } +async function resolveAuthenticatedUserFromRequest(req) { + const authToken = extractAuthTokenFromRequest(req); + const auth = verifyAccessToken(authToken); + if (!auth?.userId) { + return null; + } + + return prisma.user.findUnique({ + where: { id: auth.userId }, + }); +} + +app.post('/auth/messenger-start', async (req, res) => { + const channel = String(req.body?.channel || '').toUpperCase(); + if (channel !== 'TELEGRAM' && channel !== 'MAX') { + res.status(400).json({ error: 'Unsupported channel.' }); + return; + } + + const authenticatedUser = await resolveAuthenticatedUserFromRequest(req); + const providedEmail = String(req.body?.email || '').trim().toLowerCase(); + const email = authenticatedUser?.email?.trim().toLowerCase() || providedEmail; + const userId = authenticatedUser?.id ?? null; + + if (!userId && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + res.status(400).json({ error: 'A valid email is required.' }); + return; + } + + const session = createMessengerStartSession({ + channel, + email, + userId, + }); + + res.json({ + ok: true, + startToken: session.startToken, + expiresAt: session.expiresAt.toISOString(), + mode: userId ? 'connect' : 'login', + }); +}); + app.post('/bot/messenger-login', async (req, res) => { const webhookToken = process.env.BOT_LOGIN_WEBHOOK_TOKEN; const providedToken = req.body?.token; @@ -87,18 +137,29 @@ app.post('/bot/messenger-login', async (req, res) => { return; } - const userId = String(req.body?.userId || '').trim(); - const email = String(req.body?.email || '').trim().toLowerCase(); + const startToken = String(req.body?.startToken || '').trim(); const channelId = String(req.body?.channelId || '').trim(); const skipDispatch = req.body?.skipDispatch === true; - if (!channelId || (!userId && !email)) { - res.status(400).json({ error: 'channelId and (userId or email) are required.' }); + if (!channelId || !startToken) { + res.status(400).json({ error: 'channelId and startToken are required.' }); + return; + } + + if (!hasMessengerStartSession(startToken)) { + res.status(400).json({ error: 'Messenger start token is invalid or expired.' }); + return; + } + + const startSession = consumeMessengerStartSession(startToken); + + if (startSession.channel !== channel) { + res.status(400).json({ error: 'Start token channel mismatch.' }); return; } const user = await resolveUserForMessenger({ - userId, - email, + userId: startSession.userId, + email: startSession.email, }); if (!user) { @@ -106,34 +167,13 @@ app.post('/bot/messenger-login', async (req, res) => { return; } - await prisma.messengerConnection.updateMany({ - where: { - userId: user.id, - type: channel, - isActive: true, - NOT: { channelId }, - }, - data: { isActive: false }, - }); - - await prisma.messengerConnection.upsert({ - where: { - userId_type_channelId: { - userId: user.id, - type: channel, - channelId, - }, - }, - update: { isActive: true }, - create: { - userId: user.id, + const login = issueTemporaryLoginToken({ + userId: user.id, + messengerConnection: { type: channel, channelId, - isActive: true, }, }); - - const login = issueTemporaryLoginToken(user.id); const frontendUrl = ( process.env.WEB_FRONTEND_URL || process.env.NUXT_PUBLIC_SITE_URL || @@ -158,6 +198,7 @@ app.post('/bot/messenger-login', async (req, res) => { res.json({ ok: true, + mode: startSession.userId ? 'connect' : 'login', loginUrl, expiresAt: login.expiresAt.toISOString(), });