feat(auth): secure messenger start token flow
This commit is contained in:
54
src/auth.js
54
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
103
src/server.js
103
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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user