feat(auth): secure messenger start token flow

This commit is contained in:
Ruslan Bakiev
2026-04-03 18:12:05 +07:00
parent 8267a48cb4
commit 6c5839d6ee
3 changed files with 154 additions and 32 deletions

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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(),
});