From 8c8689a8772021b0601272fa8e56efcf0baebdf0 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:09:59 +0700 Subject: [PATCH] Add one-time code login flow with token sessions --- src/auth.js | 169 +++++++++++++++++++++++++++ src/context.js | 9 +- src/messenger.js | 132 +++++++++++++++++++++ src/resolvers.js | 277 ++++++++++++++++++++++++++++++++++++++++++++- src/schema.graphql | 52 +++++++++ 5 files changed, 633 insertions(+), 6 deletions(-) create mode 100644 src/auth.js create mode 100644 src/messenger.js diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..a77d907 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,169 @@ +import crypto from 'node:crypto'; + +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_STATIC_CODE = process.env.AUTH_STATIC_CODE || '123456'; + +const activeChallenges = new Map(); + +function sign(data) { + return crypto.createHmac('sha256', AUTH_TOKEN_SECRET).update(data).digest('base64url'); +} + +function parseCookies(cookieHeaderValue) { + if (!cookieHeaderValue) { + return {}; + } + + return cookieHeaderValue + .split(';') + .map((chunk) => chunk.trim()) + .filter(Boolean) + .reduce((acc, pair) => { + const separatorIndex = pair.indexOf('='); + if (separatorIndex <= 0) { + return acc; + } + const key = pair.slice(0, separatorIndex); + const value = pair.slice(separatorIndex + 1); + acc[key] = decodeURIComponent(value); + return acc; + }, {}); +} + +function purgeExpiredChallenges() { + const now = Date.now(); + for (const [challengeToken, challenge] of activeChallenges.entries()) { + if (challenge.expiresAt <= now) { + activeChallenges.delete(challengeToken); + } + } +} + +function maskEmail(email) { + const [local, domain] = email.split('@'); + if (!domain) { + return email; + } + + const visiblePart = local.length <= 2 ? local[0] ?? '*' : `${local[0]}${local[1]}`; + return `${visiblePart}${'*'.repeat(Math.max(local.length - visiblePart.length, 1))}@${domain}`; +} + +function maskChannelId(value) { + if (value.length <= 4) { + return '*'.repeat(value.length); + } + return `${value.slice(0, 2)}${'*'.repeat(Math.max(value.length - 4, 1))}${value.slice(-2)}`; +} + +export function extractAuthTokenFromRequest(req) { + const authorizationHeader = Array.isArray(req.headers.authorization) + ? req.headers.authorization[0] + : req.headers.authorization; + + if (authorizationHeader?.startsWith('Bearer ')) { + return authorizationHeader.slice('Bearer '.length).trim(); + } + + const cookieHeader = Array.isArray(req.headers.cookie) ? req.headers.cookie.join(';') : req.headers.cookie; + const cookies = parseCookies(cookieHeader ?? ''); + return cookies[AUTH_COOKIE_NAME] ?? null; +} + +export function issueAccessToken(userId) { + const now = Math.floor(Date.now() / 1000); + const exp = now + AUTH_TOKEN_TTL_SECONDS; + const payload = { + sub: userId, + iat: now, + exp, + jti: crypto.randomUUID(), + }; + + const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signature = sign(payloadBase64); + return { + accessToken: `${payloadBase64}.${signature}`, + expiresAt: new Date(exp * 1000), + }; +} + +export function verifyAccessToken(token) { + if (!token) { + return null; + } + + const parts = token.split('.'); + if (parts.length !== 2) { + return null; + } + + const [payloadBase64, signature] = parts; + const expectedSignature = sign(payloadBase64); + if (expectedSignature !== signature) { + return null; + } + + const payloadJson = Buffer.from(payloadBase64, 'base64url').toString('utf8'); + const payload = JSON.parse(payloadJson); + const exp = Number(payload.exp); + if (!Number.isFinite(exp) || exp <= Math.floor(Date.now() / 1000)) { + return null; + } + + return { + userId: String(payload.sub), + }; +} + +export function createLoginChallenge({ userId, channel, destination }) { + purgeExpiredChallenges(); + + const challengeToken = crypto.randomBytes(24).toString('hex'); + const expiresAt = Date.now() + AUTH_LOGIN_CHALLENGE_TTL_SECONDS * 1000; + activeChallenges.set(challengeToken, { + userId, + channel, + destination, + expiresAt, + }); + + return { + challengeToken, + expiresAt: new Date(expiresAt), + }; +} + +export function verifyLoginChallengeCode({ challengeToken, code }) { + purgeExpiredChallenges(); + + const challenge = activeChallenges.get(challengeToken); + if (!challenge) { + throw new Error('Login challenge is invalid or expired.'); + } + + if (String(code).trim() !== AUTH_STATIC_CODE) { + throw new Error('Invalid login code.'); + } + + activeChallenges.delete(challengeToken); + return { + userId: challenge.userId, + channel: challenge.channel, + destination: challenge.destination, + }; +} + +export function getStaticAuthCode() { + return AUTH_STATIC_CODE; +} + +export function maskAuthDestination(channel, destination) { + if (channel === 'EMAIL') { + return maskEmail(destination); + } + return maskChannelId(destination); +} diff --git a/src/context.js b/src/context.js index 0b3ba8a..2b9fc9c 100644 --- a/src/context.js +++ b/src/context.js @@ -1,7 +1,14 @@ import { prisma } from './prisma-client.js'; +import { extractAuthTokenFromRequest, verifyAccessToken } from './auth.js'; export async function buildContext(req) { - const userId = req.headers['x-user-id']; + const token = extractAuthTokenFromRequest(req); + const tokenPayload = verifyAccessToken(token); + const legacyUserIdHeader = Array.isArray(req.headers['x-user-id']) + ? req.headers['x-user-id'][0] + : req.headers['x-user-id']; + const userId = tokenPayload?.userId ?? legacyUserIdHeader; + const user = userId ? await prisma.user.findUnique({ where: { id: String(userId) } }) : null; diff --git a/src/messenger.js b/src/messenger.js new file mode 100644 index 0000000..51fbccf --- /dev/null +++ b/src/messenger.js @@ -0,0 +1,132 @@ +function maskChannel(channelId) { + const text = String(channelId); + if (text.length <= 6) { + return text; + } + return `${text.slice(0, 3)}***${text.slice(-3)}`; +} + +async function sendTelegramMessage(channelId, message) { + const token = process.env.TELEGRAM_BOT_TOKEN; + if (!token) { + return { + success: false, + detail: 'TELEGRAM_BOT_TOKEN is not configured.', + }; + } + + try { + const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + chat_id: channelId, + text: message, + disable_web_page_preview: true, + }), + }); + + if (!response.ok) { + const body = await response.text(); + return { + success: false, + detail: `Telegram API error (${response.status}): ${body.slice(0, 240)}`, + }; + } + + return { + success: true, + detail: `Telegram message sent to ${maskChannel(channelId)}.`, + }; + } catch (error) { + return { + success: false, + detail: `Telegram transport failed: ${error.message}`, + }; + } +} + +async function sendMaxMessage(channelId, message) { + const webhookUrl = process.env.MAX_BOT_WEBHOOK_URL; + if (!webhookUrl) { + return { + success: false, + detail: 'MAX_BOT_WEBHOOK_URL is not configured.', + }; + } + + try { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + channelId, + text: message, + source: 'fregat-apollo-backend', + }), + }); + + if (!response.ok) { + const body = await response.text(); + return { + success: false, + detail: `Max webhook error (${response.status}): ${body.slice(0, 240)}`, + }; + } + + return { + success: true, + detail: `Max message sent to ${maskChannel(channelId)}.`, + }; + } catch (error) { + return { + success: false, + detail: `Max transport failed: ${error.message}`, + }; + } +} + +export async function sendMessengerMessage({ type, channelId, message }) { + if (type === 'TELEGRAM') { + return sendTelegramMessage(channelId, message); + } + if (type === 'MAX') { + return sendMaxMessage(channelId, message); + } + + return { + success: false, + detail: `Unsupported messenger type: ${type}`, + }; +} + +export async function dispatchToUserConnections(prisma, userId, message) { + const connections = await prisma.messengerConnection.findMany({ + where: { + userId, + isActive: true, + }, + }); + + if (!connections.length) { + return []; + } + + const results = []; + for (const connection of connections) { + const result = await sendMessengerMessage({ + type: connection.type, + channelId: connection.channelId, + message, + }); + + results.push({ + type: connection.type, + channelId: connection.channelId, + ...result, + sentAt: new Date(), + }); + } + + return results; +} diff --git a/src/resolvers.js b/src/resolvers.js index 1f56d3f..7c7fd61 100644 --- a/src/resolvers.js +++ b/src/resolvers.js @@ -1,5 +1,13 @@ import crypto from 'node:crypto'; +import { + createLoginChallenge, + getStaticAuthCode, + issueAccessToken, + maskAuthDestination, + verifyLoginChallengeCode, +} from './auth.js'; +import { dispatchToUserConnections, sendMessengerMessage } from './messenger.js'; import { dateTimeScalar, jsonScalar } from './scalars.js'; const ACTIVE_ORDER_STATUSES = ['NEW', 'MANAGER_PROCESSING', 'WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'IN_PROGRESS']; @@ -10,7 +18,7 @@ function toFloat(value) { function requireUser(context) { if (!context.user) { - throw new Error('Authentication required. Pass x-user-id header.'); + throw new Error('Authentication required.'); } return context.user; } @@ -42,6 +50,91 @@ function invitationToken() { return crypto.randomBytes(24).toString('hex'); } +function formatOrderStatusMessage(order, status, note) { + const suffix = note ? `\nКомментарий: ${note}` : ''; + return `Заказ ${order.code} изменил статус: ${status}.${suffix}`; +} + +async function notifyOrderStakeholders(context, order, status, note) { + const recipients = [order.customerId, order.managerId].filter(Boolean); + if (!recipients.length) { + return; + } + + const message = formatOrderStatusMessage(order, status, note); + const uniqueRecipients = [...new Set(recipients)]; + await Promise.allSettled( + uniqueRecipients.map((userId) => dispatchToUserConnections(context.prisma, userId, message)), + ); +} + +function byCreatedAtDesc(a, b) { + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); +} + +async function collectNotificationHistory(context, userId, channel, limit) { + const [events, bonuses, withdrawals] = await Promise.all([ + context.prisma.orderStatusEvent.findMany({ + where: { + order: { + OR: [{ customerId: userId }, { managerId: userId }], + }, + }, + include: { + order: { + select: { id: true, code: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + take: limit * 2, + }), + context.prisma.bonusTransaction.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: limit, + }), + context.prisma.rewardWithdrawalRequest.findMany({ + where: { + requesterId: userId, + reviewedById: { not: null }, + }, + orderBy: { updatedAt: 'desc' }, + take: limit, + }), + ]); + + const eventHistory = events.map((event) => ({ + id: `ORDER_${event.id}_${channel}`, + channel, + title: `Статус заказа ${event.order.code}`, + message: formatOrderStatusMessage(event.order, event.status, event.note), + createdAt: event.createdAt, + orderId: event.orderId, + })); + + const bonusHistory = bonuses.map((bonus) => ({ + id: `BONUS_${bonus.id}_${channel}`, + channel, + title: 'Реферальный бонус', + message: `Начисление ${toFloat(bonus.amount)}. Причина: ${bonus.reason}`, + createdAt: bonus.createdAt, + orderId: bonus.orderId, + })); + + const withdrawalHistory = withdrawals.map((withdrawal) => ({ + id: `WITHDRAW_${withdrawal.id}_${channel}`, + channel, + title: 'Заявка на вывод вознаграждения', + message: `Статус: ${withdrawal.status}.${withdrawal.reviewComment ? ` Комментарий: ${withdrawal.reviewComment}` : ''}`, + createdAt: withdrawal.updatedAt, + orderId: null, + })); + + return [...eventHistory, ...bonusHistory, ...withdrawalHistory] + .sort(byCreatedAtDesc) + .slice(0, limit); +} + export const resolvers = { DateTime: dateTimeScalar, JSON: jsonScalar, @@ -51,6 +144,26 @@ export const resolvers = { me: (_, __, context) => context.user, + myMessengerConnections: async (_, __, context) => { + const user = requireUser(context); + return context.prisma.messengerConnection.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: 'desc' }, + }); + }, + + myNotificationHistory: async (_, { channel, limit }, context) => { + const user = requireUser(context); + const normalizedLimit = Math.min(Math.max(limit ?? 50, 1), 200); + return collectNotificationHistory(context, user.id, channel, normalizedLimit); + }, + + managerNotificationHistory: async (_, { userId, channel, limit }, context) => { + requireRole(context, 'MANAGER'); + const normalizedLimit = Math.min(Math.max(limit ?? 50, 1), 200); + return collectNotificationHistory(context, userId, channel, normalizedLimit); + }, + clientProducts: (_, __, context) => context.prisma.product.findMany({ where: { isActive: true }, @@ -143,6 +256,91 @@ export const resolvers = { }, Mutation: { + requestLoginCode: async (_, { input }, context) => { + 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', + }, + }, + }); + } 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.'); + } + + const challenge = createLoginChallenge({ + userId: user.id, + channel: input.channel, + destination, + }); + + 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, + channel: input.channel, + destination: maskAuthDestination(input.channel, destination), + expiresAt: challenge.expiresAt, + }; + }, + + verifyLoginCode: async (_, { input }, context) => { + const challenge = verifyLoginChallengeCode({ + challengeToken: input.challengeToken, + code: input.code, + }); + + const user = await context.prisma.user.findUnique({ + where: { id: challenge.userId }, + }); + if (!user) { + throw new Error('User is not available for this login challenge.'); + } + + const session = issueAccessToken(user.id); + return { + accessToken: session.accessToken, + expiresAt: session.expiresAt, + user, + }; + }, + registerSelf: (_, { input }, context) => context.prisma.registrationRequest.create({ data: { @@ -239,6 +437,41 @@ export const resolvers = { }); }, + sendTestMessengerMessage: async (_, { type, channelId, message }, context) => { + const user = requireUser(context); + let targetChannelId = channelId; + + if (!targetChannelId) { + const connection = await context.prisma.messengerConnection.findFirst({ + where: { + userId: user.id, + type, + isActive: true, + }, + orderBy: { createdAt: 'desc' }, + }); + targetChannelId = connection?.channelId ?? null; + } + + if (!targetChannelId) { + throw new Error(`No active ${type} channel is connected for this user.`); + } + + const dispatch = await sendMessengerMessage({ + type, + channelId: targetChannelId, + message: message ?? `Тестовое уведомление Fregat (${type})`, + }); + + return { + type, + channelId: targetChannelId, + success: dispatch.success, + detail: dispatch.detail, + sentAt: new Date(), + }; + }, + submitReadyOrder: async (_, { input }, context) => { const customer = requireRole(context, 'CLIENT'); if (!input.items.length) { @@ -280,6 +513,7 @@ export const resolvers = { }); await appendOrderEvent(context.prisma, order.id, 'NEW', customer.id, 'Ready order created by client'); + await notifyOrderStakeholders(context, order, 'NEW', 'Ready order created by client'); return context.prisma.order.findUnique({ where: { id: order.id }, @@ -312,6 +546,7 @@ export const resolvers = { }); await appendOrderEvent(context.prisma, order.id, 'NEW', customer.id, 'Calculation request created by client'); + await notifyOrderStakeholders(context, order, 'NEW', 'Calculation request created by client'); return context.prisma.order.findUnique({ where: { id: order.id }, @@ -333,6 +568,7 @@ export const resolvers = { }); await appendOrderEvent(context.prisma, order.id, 'WAITING_DOUBLE_CONFIRM', manager.id, 'Offer is published by manager'); + await notifyOrderStakeholders(context, order, 'WAITING_DOUBLE_CONFIRM', 'Offer is published by manager'); return context.prisma.order.findUnique({ where: { id: order.id }, @@ -369,6 +605,12 @@ export const resolvers = { customer.id, decision === 'APPROVE' ? 'Client approved offer' : 'Client rejected offer', ); + await notifyOrderStakeholders( + context, + updated, + status, + decision === 'APPROVE' ? 'Client approved offer' : 'Client rejected offer', + ); return context.prisma.order.findUnique({ where: { id: updated.id }, @@ -405,6 +647,12 @@ export const resolvers = { manager.id, decision === 'APPROVE' ? 'Manager approved order' : 'Manager rejected order', ); + await notifyOrderStakeholders( + context, + updated, + status, + decision === 'APPROVE' ? 'Manager approved order' : 'Manager rejected order', + ); return context.prisma.order.findUnique({ where: { id: updated.id }, @@ -424,6 +672,7 @@ export const resolvers = { }); await appendOrderEvent(context.prisma, updated.id, 'MANAGER_BLOCKED', manager.id, input.reason); + await notifyOrderStakeholders(context, updated, 'MANAGER_BLOCKED', input.reason); return context.prisma.order.findUnique({ where: { id: updated.id }, @@ -447,6 +696,7 @@ export const resolvers = { }); await appendOrderEvent(context.prisma, updated.id, 'IN_PROGRESS', manager.id, 'Order moved to in-progress'); + await notifyOrderStakeholders(context, updated, 'IN_PROGRESS', 'Order moved to in-progress'); return context.prisma.order.findUnique({ where: { id: updated.id }, @@ -470,6 +720,7 @@ export const resolvers = { }); await appendOrderEvent(context.prisma, updated.id, 'COMPLETED', manager.id, 'Order completed'); + await notifyOrderStakeholders(context, updated, 'COMPLETED', 'Order completed'); return context.prisma.order.findUnique({ where: { id: updated.id }, @@ -487,9 +738,9 @@ export const resolvers = { }); }, - addBonusTransaction: (_, { input }, context) => { + addBonusTransaction: async (_, { input }, context) => { requireRole(context, 'MANAGER'); - return context.prisma.bonusTransaction.create({ + const transaction = await context.prisma.bonusTransaction.create({ data: { userId: input.userId, amount: input.amount, @@ -497,6 +748,14 @@ export const resolvers = { orderId: input.orderId, }, }); + + await dispatchToUserConnections( + context.prisma, + transaction.userId, + `Начислен бонус: ${toFloat(transaction.amount)}. Причина: ${transaction.reason}`, + ); + + return transaction; }, requestRewardWithdrawal: (_, { input }, context) => { @@ -513,9 +772,9 @@ export const resolvers = { }); }, - reviewRewardWithdrawal: (_, { input }, context) => { + reviewRewardWithdrawal: async (_, { input }, context) => { const manager = requireRole(context, 'MANAGER'); - return context.prisma.rewardWithdrawalRequest.update({ + const withdrawal = await context.prisma.rewardWithdrawalRequest.update({ where: { id: input.withdrawalId }, data: { reviewedById: manager.id, @@ -523,6 +782,14 @@ export const resolvers = { reviewComment: input.reviewComment, }, }); + + await dispatchToUserConnections( + context.prisma, + withdrawal.requesterId, + `Заявка на вывод вознаграждения обновлена: ${withdrawal.status}.${withdrawal.reviewComment ? ` Комментарий: ${withdrawal.reviewComment}` : ''}`, + ); + + return withdrawal; }, }, diff --git a/src/schema.graphql b/src/schema.graphql index 372b8db..53ae107 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -11,6 +11,12 @@ enum MessengerType { MAX } +enum LoginChannel { + EMAIL + TELEGRAM + MAX +} + enum RegistrationStatus { PENDING APPROVED @@ -59,6 +65,19 @@ type User { company: Company } +type AuthCodeRequestResult { + challengeToken: String! + channel: LoginChannel! + destination: String! + expiresAt: DateTime! +} + +type AuthSession { + accessToken: String! + expiresAt: DateTime! + user: User! +} + type Invitation { id: ID! token: String! @@ -92,6 +111,23 @@ type MessengerConnection { isActive: Boolean! } +type MessengerDispatchResult { + type: MessengerType! + channelId: String! + success: Boolean! + detail: String! + sentAt: DateTime! +} + +type NotificationHistoryItem { + id: ID! + channel: MessengerType! + title: String! + message: String! + createdAt: DateTime! + orderId: ID +} + type Warehouse { id: ID! code: String! @@ -186,6 +222,9 @@ type ReferralStats { type Query { healthcheck: String! me: User + myMessengerConnections: [MessengerConnection!]! + myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]! + managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]! clientProducts: [Product!]! myOrders: [Order!]! myCurrentOrders: [Order!]! @@ -194,6 +233,16 @@ type Query { referralStats: ReferralStats! } +input RequestLoginCodeInput { + channel: LoginChannel! + destination: String! +} + +input VerifyLoginCodeInput { + challengeToken: String! + code: String! +} + input RegisterSelfInput { companyName: String! inn: String @@ -272,11 +321,14 @@ input ReviewRewardWithdrawalInput { } type Mutation { + requestLoginCode(input: RequestLoginCodeInput!): AuthCodeRequestResult! + verifyLoginCode(input: VerifyLoginCodeInput!): AuthSession! registerSelf(input: RegisterSelfInput!): RegistrationRequest! reviewRegistrationRequest(input: ReviewRegistrationRequestInput!): RegistrationRequest! createInvitation(input: CreateInvitationInput!): Invitation! acceptInvitation(input: AcceptInvitationInput!): User! connectMessenger(input: ConnectMessengerInput!): MessengerConnection! + sendTestMessengerMessage(type: MessengerType!, channelId: String, message: String): MessengerDispatchResult! submitReadyOrder(input: SubmitReadyOrderInput!): Order! submitCalculationOrder(input: SubmitCalculationOrderInput!): Order!