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_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_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_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 AUTH_STATIC_CODE = process.env.AUTH_STATIC_CODE || '123456';
|
||||||
|
|
||||||
const activeChallenges = new Map();
|
const activeChallenges = new Map();
|
||||||
const activeLoginTokens = new Map();
|
const activeLoginTokens = new Map();
|
||||||
|
const activeMessengerStartSessions = new Map();
|
||||||
|
|
||||||
function sign(data) {
|
function sign(data) {
|
||||||
return crypto.createHmac('sha256', AUTH_TOKEN_SECRET).update(data).digest('base64url');
|
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) {
|
function maskEmail(email) {
|
||||||
const [local, domain] = email.split('@');
|
const [local, domain] = email.split('@');
|
||||||
if (!domain) {
|
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();
|
purgeExpiredLoginTokens();
|
||||||
|
|
||||||
const loginToken = crypto.randomBytes(24).toString('hex');
|
const loginToken = crypto.randomBytes(24).toString('hex');
|
||||||
const expiresAt = Date.now() + AUTH_LOGIN_LINK_TTL_SECONDS * 1000;
|
const expiresAt = Date.now() + AUTH_LOGIN_LINK_TTL_SECONDS * 1000;
|
||||||
activeLoginTokens.set(loginToken, {
|
activeLoginTokens.set(loginToken, {
|
||||||
userId,
|
userId,
|
||||||
|
messengerConnection,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,6 +246,7 @@ export function consumeTemporaryLoginToken(loginToken) {
|
|||||||
activeLoginTokens.delete(loginToken);
|
activeLoginTokens.delete(loginToken);
|
||||||
return {
|
return {
|
||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
|
messengerConnection: payload.messengerConnection,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -504,6 +504,35 @@ export const resolvers = {
|
|||||||
throw new Error('User for this login token was not found.');
|
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);
|
const session = issueAccessToken(user.id);
|
||||||
return {
|
return {
|
||||||
accessToken: session.accessToken,
|
accessToken: session.accessToken,
|
||||||
|
|||||||
101
src/server.js
101
src/server.js
@@ -8,7 +8,14 @@ import express from 'express';
|
|||||||
import { ApolloServer } from '@apollo/server';
|
import { ApolloServer } from '@apollo/server';
|
||||||
import { expressMiddleware } from '@as-integrations/express5';
|
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 { buildContext } from './context.js';
|
||||||
import { sendMessengerMessage } from './messenger.js';
|
import { sendMessengerMessage } from './messenger.js';
|
||||||
import { prisma } from './prisma-client.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) => {
|
app.post('/bot/messenger-login', async (req, res) => {
|
||||||
const webhookToken = process.env.BOT_LOGIN_WEBHOOK_TOKEN;
|
const webhookToken = process.env.BOT_LOGIN_WEBHOOK_TOKEN;
|
||||||
const providedToken = req.body?.token;
|
const providedToken = req.body?.token;
|
||||||
@@ -87,18 +137,29 @@ app.post('/bot/messenger-login', async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = String(req.body?.userId || '').trim();
|
const startToken = String(req.body?.startToken || '').trim();
|
||||||
const email = String(req.body?.email || '').trim().toLowerCase();
|
|
||||||
const channelId = String(req.body?.channelId || '').trim();
|
const channelId = String(req.body?.channelId || '').trim();
|
||||||
const skipDispatch = req.body?.skipDispatch === true;
|
const skipDispatch = req.body?.skipDispatch === true;
|
||||||
if (!channelId || (!userId && !email)) {
|
if (!channelId || !startToken) {
|
||||||
res.status(400).json({ error: 'channelId and (userId or email) are required.' });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await resolveUserForMessenger({
|
const user = await resolveUserForMessenger({
|
||||||
userId,
|
userId: startSession.userId,
|
||||||
email,
|
email: startSession.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -106,34 +167,13 @@ app.post('/bot/messenger-login', async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.messengerConnection.updateMany({
|
const login = issueTemporaryLoginToken({
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
type: channel,
|
|
||||||
isActive: true,
|
|
||||||
NOT: { channelId },
|
|
||||||
},
|
|
||||||
data: { isActive: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.messengerConnection.upsert({
|
|
||||||
where: {
|
|
||||||
userId_type_channelId: {
|
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
messengerConnection: {
|
||||||
type: channel,
|
type: channel,
|
||||||
channelId,
|
channelId,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
update: { isActive: true },
|
|
||||||
create: {
|
|
||||||
userId: user.id,
|
|
||||||
type: channel,
|
|
||||||
channelId,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const login = issueTemporaryLoginToken(user.id);
|
|
||||||
const frontendUrl = (
|
const frontendUrl = (
|
||||||
process.env.WEB_FRONTEND_URL ||
|
process.env.WEB_FRONTEND_URL ||
|
||||||
process.env.NUXT_PUBLIC_SITE_URL ||
|
process.env.NUXT_PUBLIC_SITE_URL ||
|
||||||
@@ -158,6 +198,7 @@ app.post('/bot/messenger-login', async (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
mode: startSession.userId ? 'connect' : 'login',
|
||||||
loginUrl,
|
loginUrl,
|
||||||
expiresAt: login.expiresAt.toISOString(),
|
expiresAt: login.expiresAt.toISOString(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user