Implement messenger login tokens and bot login endpoint

This commit is contained in:
Ruslan Bakiev
2026-04-01 19:20:42 +07:00
parent 8c8689a877
commit 23edfbe7ff
5 changed files with 172 additions and 41 deletions

View File

@@ -4,9 +4,11 @@ const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME || 'fregat_auth_token';
const AUTH_TOKEN_SECRET = process.env.AUTH_TOKEN_SECRET || 'fregat-auth-dev-secret';
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 ?? 10 * 60);
const AUTH_STATIC_CODE = process.env.AUTH_STATIC_CODE || '123456';
const activeChallenges = new Map();
const activeLoginTokens = new Map();
function sign(data) {
return crypto.createHmac('sha256', AUTH_TOKEN_SECRET).update(data).digest('base64url');
@@ -42,6 +44,15 @@ function purgeExpiredChallenges() {
}
}
function purgeExpiredLoginTokens() {
const now = Date.now();
for (const [loginToken, payload] of activeLoginTokens.entries()) {
if (payload.expiresAt <= now) {
activeLoginTokens.delete(loginToken);
}
}
}
function maskEmail(email) {
const [local, domain] = email.split('@');
if (!domain) {
@@ -157,6 +168,36 @@ export function verifyLoginChallengeCode({ challengeToken, code }) {
};
}
export function issueTemporaryLoginToken(userId) {
purgeExpiredLoginTokens();
const loginToken = crypto.randomBytes(24).toString('hex');
const expiresAt = Date.now() + AUTH_LOGIN_LINK_TTL_SECONDS * 1000;
activeLoginTokens.set(loginToken, {
userId,
expiresAt,
});
return {
loginToken,
expiresAt: new Date(expiresAt),
};
}
export function consumeTemporaryLoginToken(loginToken) {
purgeExpiredLoginTokens();
const payload = activeLoginTokens.get(loginToken);
if (!payload) {
throw new Error('Login token is invalid or expired.');
}
activeLoginTokens.delete(loginToken);
return {
userId: payload.userId,
};
}
export function getStaticAuthCode() {
return AUTH_STATIC_CODE;
}

View File

@@ -6,7 +6,7 @@ function maskChannel(channelId) {
return `${text.slice(0, 3)}***${text.slice(-3)}`;
}
async function sendTelegramMessage(channelId, message) {
async function sendTelegramMessage(channelId, message, options = {}) {
const token = process.env.TELEGRAM_BOT_TOKEN;
if (!token) {
return {
@@ -23,6 +23,18 @@ async function sendTelegramMessage(channelId, message) {
chat_id: channelId,
text: message,
disable_web_page_preview: true,
...(options.buttonUrl
? {
reply_markup: {
inline_keyboard: [[
{
text: options.buttonText || 'Открыть кабинет',
url: options.buttonUrl,
},
]],
},
}
: {}),
}),
});
@@ -46,7 +58,7 @@ async function sendTelegramMessage(channelId, message) {
}
}
async function sendMaxMessage(channelId, message) {
async function sendMaxMessage(channelId, message, options = {}) {
const webhookUrl = process.env.MAX_BOT_WEBHOOK_URL;
if (!webhookUrl) {
return {
@@ -63,6 +75,14 @@ async function sendMaxMessage(channelId, message) {
channelId,
text: message,
source: 'fregat-apollo-backend',
...(options.buttonUrl
? {
button: {
text: options.buttonText || 'Открыть кабинет',
url: options.buttonUrl,
},
}
: {}),
}),
});
@@ -86,12 +106,13 @@ async function sendMaxMessage(channelId, message) {
}
}
export async function sendMessengerMessage({ type, channelId, message }) {
export async function sendMessengerMessage({ type, channelId, message, buttonUrl, buttonText }) {
const options = { buttonUrl, buttonText };
if (type === 'TELEGRAM') {
return sendTelegramMessage(channelId, message);
return sendTelegramMessage(channelId, message, options);
}
if (type === 'MAX') {
return sendMaxMessage(channelId, message);
return sendMaxMessage(channelId, message, options);
}
return {

View File

@@ -1,6 +1,7 @@
import crypto from 'node:crypto';
import {
consumeTemporaryLoginToken,
createLoginChallenge,
getStaticAuthCode,
issueAccessToken,
@@ -257,15 +258,16 @@ export const resolvers = {
Mutation: {
requestLoginCode: async (_, { input }, context) => {
if (input.channel !== 'EMAIL') {
throw new Error('Code login is supported only for EMAIL channel.');
}
const destination = input.destination.trim();
if (!destination) {
throw new Error('Destination is required.');
}
let user = null;
if (input.channel === 'EMAIL') {
user = await context.prisma.user.findFirst({
const user = await context.prisma.user.findFirst({
where: {
email: {
equals: destination,
@@ -273,18 +275,6 @@ export const resolvers = {
},
},
});
} else {
const connection = await context.prisma.messengerConnection.findFirst({
where: {
type: input.channel,
channelId: destination,
isActive: true,
},
include: { user: true },
orderBy: { createdAt: 'desc' },
});
user = connection?.user ?? null;
}
if (!user) {
throw new Error('User for this destination was not found.');
@@ -297,20 +287,7 @@ export const resolvers = {
});
const code = getStaticAuthCode();
const authMessage = `Код входа в Fregat: ${code}`;
if (input.channel === 'EMAIL') {
console.info(`[auth] login code for ${destination}: ${code}`);
} else {
const dispatch = await sendMessengerMessage({
type: input.channel,
channelId: destination,
message: authMessage,
});
if (!dispatch.success) {
throw new Error(`Unable to send login code: ${dispatch.detail}`);
}
}
return {
challengeToken: challenge.challengeToken,
@@ -341,6 +318,23 @@ export const resolvers = {
};
},
consumeLoginToken: async (_, { token }, context) => {
const login = consumeTemporaryLoginToken(token);
const user = await context.prisma.user.findUnique({
where: { id: login.userId },
});
if (!user) {
throw new Error('User for this login token was not found.');
}
const session = issueAccessToken(user.id);
return {
accessToken: session.accessToken,
expiresAt: session.expiresAt,
user,
};
},
registerSelf: (_, { input }, context) =>
context.prisma.registrationRequest.create({
data: {

View File

@@ -323,6 +323,7 @@ input ReviewRewardWithdrawalInput {
type Mutation {
requestLoginCode(input: RequestLoginCodeInput!): AuthCodeRequestResult!
verifyLoginCode(input: VerifyLoginCodeInput!): AuthSession!
consumeLoginToken(token: String!): AuthSession!
registerSelf(input: RegisterSelfInput!): RegistrationRequest!
reviewRegistrationRequest(input: ReviewRegistrationRequestInput!): RegistrationRequest!
createInvitation(input: CreateInvitationInput!): Invitation!

View File

@@ -8,7 +8,9 @@ import express from 'express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@as-integrations/express5';
import { issueTemporaryLoginToken } from './auth.js';
import { buildContext } from './context.js';
import { sendMessengerMessage } from './messenger.js';
import { prisma } from './prisma-client.js';
import { resolvers } from './resolvers.js';
@@ -29,6 +31,78 @@ app.get('/healthz', (_, res) => {
res.json({ status: 'ok' });
});
app.post('/bot/messenger-login', async (req, res) => {
const webhookToken = process.env.BOT_LOGIN_WEBHOOK_TOKEN;
const providedToken = req.body?.token;
if (!webhookToken || providedToken !== webhookToken) {
res.status(401).json({ error: 'Unauthorized bot request.' });
return;
}
const channel = String(req.body?.channel || '').toUpperCase();
if (channel !== 'TELEGRAM' && channel !== 'MAX') {
res.status(400).json({ error: 'Unsupported channel.' });
return;
}
const userId = String(req.body?.userId || '').trim();
const channelId = String(req.body?.channelId || '').trim();
if (!userId || !channelId) {
res.status(400).json({ error: 'userId and channelId are required.' });
return;
}
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
res.status(404).json({ error: 'User not found.' });
return;
}
await prisma.messengerConnection.upsert({
where: {
userId_type_channelId: {
userId: user.id,
type: channel,
channelId,
},
},
update: { isActive: true },
create: {
userId: user.id,
type: channel,
channelId,
isActive: true,
},
});
const login = issueTemporaryLoginToken(user.id);
const frontendUrl = (
process.env.WEB_FRONTEND_URL ||
process.env.NUXT_PUBLIC_SITE_URL ||
'http://localhost:3000'
).replace(/\/$/, '');
const loginUrl = `${frontendUrl}/login?login_token=${encodeURIComponent(login.loginToken)}`;
const dispatch = await sendMessengerMessage({
type: channel,
channelId,
message: 'Вход подтвержден. Нажмите кнопку, чтобы открыть личный кабинет.',
buttonUrl: loginUrl,
buttonText: 'Открыть кабинет',
});
if (!dispatch.success) {
res.status(502).json({ error: dispatch.detail });
return;
}
res.json({
ok: true,
loginUrl,
expiresAt: login.expiresAt.toISOString(),
});
});
app.use(
'/graphql',
expressMiddleware(server, {