diff --git a/src/auth.js b/src/auth.js index a77d907..4409d10 100644 --- a/src/auth.js +++ b/src/auth.js @@ -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; } diff --git a/src/messenger.js b/src/messenger.js index 51fbccf..167792b 100644 --- a/src/messenger.js +++ b/src/messenger.js @@ -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 { diff --git a/src/resolvers.js b/src/resolvers.js index 7c7fd61..9587e14 100644 --- a/src/resolvers.js +++ b/src/resolvers.js @@ -1,6 +1,7 @@ import crypto from 'node:crypto'; import { + consumeTemporaryLoginToken, createLoginChallenge, getStaticAuthCode, issueAccessToken, @@ -257,34 +258,23 @@ 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({ - where: { - email: { - equals: destination, - mode: 'insensitive', - }, + const user = await context.prisma.user.findFirst({ + where: { + email: { + equals: destination, + mode: 'insensitive', }, - }); - } 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}`); - } - } + console.info(`[auth] login code for ${destination}: ${code}`); 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: { diff --git a/src/schema.graphql b/src/schema.graphql index 53ae107..a832894 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -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! diff --git a/src/server.js b/src/server.js index 35a3e0b..d6c0dcd 100644 --- a/src/server.js +++ b/src/server.js @@ -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, {