From b321075293cb813ffab7164167f334b771c96bd9 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:46:57 +0700 Subject: [PATCH] Add standalone bonus program auth flow --- src/auth.js | 58 ++++++++++++++++++++++++- src/notification-templates.js | 25 ++++++++++- src/resolvers.js | 39 +++++++++++++++++ src/schema.graphql | 8 ++++ src/server.js | 82 ++++++++++++++++++++++++++++++----- 5 files changed, 197 insertions(+), 15 deletions(-) diff --git a/src/auth.js b/src/auth.js index 75ac341..367d3a6 100644 --- a/src/auth.js +++ b/src/auth.js @@ -6,6 +6,7 @@ const AUTH_TOKEN_TTL_SECONDS = Number(process.env.AUTH_TOKEN_TTL_SECONDS ?? 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_MESSENGER_START_TTL_SECONDS = Number(process.env.AUTH_MESSENGER_START_TTL_SECONDS ?? 10 * 60); +const BONUS_PROGRAM_LINK_TTL_SECONDS = Number(process.env.BONUS_PROGRAM_LINK_TTL_SECONDS ?? 7 * 24 * 60 * 60); const AUTH_STATIC_CODE = process.env.AUTH_STATIC_CODE || '123456'; const activeChallenges = new Map(); @@ -179,7 +180,7 @@ export function verifyLoginChallengeCode({ challengeToken, code }) { }; } -export function createMessengerStartSession({ channel, email, userId, redirectPath }) { +export function createMessengerStartSession({ channel, email, userId, redirectPath, targetApp = 'MAIN' }) { purgeExpiredMessengerStartSessions(); const startToken = crypto.randomBytes(24).toString('base64url'); @@ -189,6 +190,7 @@ export function createMessengerStartSession({ channel, email, userId, redirectPa email, userId, redirectPath, + targetApp, expiresAt, }); @@ -212,6 +214,7 @@ export function consumeMessengerStartSession(startToken) { email: payload.email, userId: payload.userId, redirectPath: payload.redirectPath, + targetApp: payload.targetApp || 'MAIN', }; } @@ -237,6 +240,59 @@ export function issueTemporaryLoginToken({ userId, messengerConnection = null }) }; } +export function issueBonusProgramLinkToken({ userId }) { + const now = Math.floor(Date.now() / 1000); + const exp = now + BONUS_PROGRAM_LINK_TTL_SECONDS; + const payload = { + type: 'BONUS_PROGRAM_LINK', + sub: userId, + iat: now, + exp, + jti: crypto.randomUUID(), + }; + + const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signature = sign(payloadBase64); + + return { + token: `${payloadBase64}.${signature}`, + expiresAt: new Date(exp * 1000), + }; +} + +export function verifyBonusProgramLinkToken(token) { + if (!token) { + throw new Error('Bonus program token is required.'); + } + + const parts = String(token).split('.'); + if (parts.length !== 2) { + throw new Error('Bonus program token is invalid.'); + } + + const [payloadBase64, signature] = parts; + const expectedSignature = sign(payloadBase64); + if (expectedSignature !== signature) { + throw new Error('Bonus program token signature is invalid.'); + } + + const payloadJson = Buffer.from(payloadBase64, 'base64url').toString('utf8'); + const payload = JSON.parse(payloadJson); + if (payload.type !== 'BONUS_PROGRAM_LINK') { + throw new Error('Bonus program token type is invalid.'); + } + + const exp = Number(payload.exp); + if (!Number.isFinite(exp) || exp <= Math.floor(Date.now() / 1000)) { + throw new Error('Bonus program token has expired.'); + } + + return { + userId: String(payload.sub), + expiresAt: new Date(exp * 1000), + }; +} + export function consumeTemporaryLoginToken(loginToken) { purgeExpiredLoginTokens(); diff --git a/src/notification-templates.js b/src/notification-templates.js index f23f511..378058d 100644 --- a/src/notification-templates.js +++ b/src/notification-templates.js @@ -57,6 +57,27 @@ export function buildBonusProgramPath(entry = 'bonus-message') { return query ? `/bonus-program?${query}` : '/bonus-program'; } +export function buildBonusProgramUrl(entry = 'bonus-message') { + const bonusBaseUrl = String( + process.env.BONUS_FRONTEND_URL || + process.env.BONUS_PUBLIC_BASE_URL || + '', + ).trim().replace(/\/$/, ''); + + if (bonusBaseUrl) { + const params = new URLSearchParams(); + const normalizedEntry = String(entry || '').trim(); + if (normalizedEntry) { + params.set('entry', normalizedEntry); + } + + const query = params.toString(); + return query ? `${bonusBaseUrl}/?${query}` : `${bonusBaseUrl}/`; + } + + return buildFrontendAppUrl(buildBonusProgramPath(entry)); +} + export function buildLoginCodeEmailTemplate({ code, expiresAt }) { const expiresText = new Date(expiresAt).toLocaleString('ru-RU', { hour12: false }); const body = [ @@ -144,7 +165,7 @@ export function buildBonusCreditTemplate({ amount }) { body, message: body.join('\n'), buttonText: 'Открыть бонусную программу', - buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('balance')), + buttonUrl: buildBonusProgramUrl('balance'), }; } @@ -167,7 +188,7 @@ export function buildWithdrawalReviewNotificationTemplate({ status, reviewCommen body, message: body.join('\n'), buttonText: 'Открыть бонусную программу', - buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('withdrawal-review')), + buttonUrl: buildBonusProgramUrl('withdrawal-review'), }; } diff --git a/src/resolvers.js b/src/resolvers.js index 9ff90c4..23ab472 100644 --- a/src/resolvers.js +++ b/src/resolvers.js @@ -4,6 +4,7 @@ import { consumeTemporaryLoginToken, createLoginChallenge, getStaticAuthCode, + issueBonusProgramLinkToken, issueAccessToken, maskAuthDestination, verifyLoginChallengeCode, @@ -50,6 +51,30 @@ function latestDate(...values) { return new Date(Math.max(...timestamps)); } +function buildBonusProgramLinkUrl(token) { + const baseUrl = String( + process.env.BONUS_FRONTEND_URL || + process.env.BONUS_PUBLIC_BASE_URL || + '', + ).trim().replace(/\/$/, ''); + + if (baseUrl) { + return `${baseUrl}/?bonus_token=${encodeURIComponent(token)}`; + } + + const fallbackBaseUrl = String( + process.env.WEB_FRONTEND_URL || + process.env.NUXT_PUBLIC_SITE_URL || + '', + ).trim().replace(/\/$/, ''); + + if (!fallbackBaseUrl) { + return `/?bonus_token=${encodeURIComponent(token)}`; + } + + return `${fallbackBaseUrl}/bonus-program?bonus_token=${encodeURIComponent(token)}`; +} + function requireUser(context) { if (!context.user) { throw new Error('Authentication required.'); @@ -2052,6 +2077,20 @@ export const resolvers = { }); }, + createBonusProgramLink: async (_, { userId }, context) => { + const manager = requireManagerAccess(context); + await assertManagerCanAccessUser(context.prisma, manager, userId); + + const issued = issueBonusProgramLinkToken({ userId }); + + return { + userId, + token: issued.token, + url: buildBonusProgramLinkUrl(issued.token), + expiresAt: issued.expiresAt, + }; + }, + addBonusTransaction: async (_, { input }, context) => { const manager = requireManagerAccess(context); await assertManagerCanAccessUser(context.prisma, manager, input.userId); diff --git a/src/schema.graphql b/src/schema.graphql index 58bc239..a9973d5 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -213,6 +213,13 @@ type IntegrationSyncDashboard { items: [IntegrationSyncItem!]! } +type BonusProgramLink { + userId: ID! + token: String! + url: String! + expiresAt: DateTime! +} + type Warehouse { id: ID! code: String! @@ -563,6 +570,7 @@ type Mutation { clientReviewOrder(orderId: ID!, decision: Decision!): Order! createReferral(input: CreateReferralInput!): ReferralLink! + createBonusProgramLink(userId: ID!): BonusProgramLink! addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction! requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest! reviewRewardWithdrawal(input: ReviewRewardWithdrawalInput!): RewardWithdrawalRequest! diff --git a/src/server.js b/src/server.js index ee8040f..6520a41 100644 --- a/src/server.js +++ b/src/server.js @@ -17,6 +17,7 @@ import { hasMessengerStartSession, issueAccessToken, issueTemporaryLoginToken, + verifyBonusProgramLinkToken, verifyAccessToken, } from './auth.js'; import { canManagerAccessUser, isManagerRole } from './access.js'; @@ -103,6 +104,10 @@ function normalizeRedirectPath(value) { return redirectPath; } +function normalizeTargetApp(value) { + return String(value || '').trim().toUpperCase() === 'BONUS' ? 'BONUS' : 'MAIN'; +} + function presentTelegramMiniAppUser(user) { return presentMiniAppUser(user, 'Пользователь Telegram'); } @@ -160,6 +165,7 @@ app.post('/auth/messenger-start', async (req, res) => { const email = authenticatedUser?.email?.trim().toLowerCase() || providedEmail; const userId = authenticatedUser?.id ?? null; const redirectPath = normalizeRedirectPath(req.body?.redirectPath); + const targetApp = normalizeTargetApp(req.body?.targetApp); if (!userId && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { res.status(400).json({ error: 'A valid email is required.' }); @@ -171,6 +177,7 @@ app.post('/auth/messenger-start', async (req, res) => { email, userId, redirectPath, + targetApp, }); res.json({ @@ -181,6 +188,43 @@ app.post('/auth/messenger-start', async (req, res) => { }); }); +app.post('/auth/bonus-program-start', async (req, res) => { + const channel = String(req.body?.channel || 'TELEGRAM').toUpperCase(); + if (channel !== 'TELEGRAM') { + res.status(400).json({ error: 'Only Telegram is supported for the bonus program.' }); + return; + } + + const token = String(req.body?.token || '').trim(); + if (!token) { + res.status(400).json({ error: 'Bonus program token is required.' }); + return; + } + + let payload; + try { + payload = verifyBonusProgramLinkToken(token); + } catch (error) { + res.status(401).json({ error: error.message }); + return; + } + + const session = createMessengerStartSession({ + channel, + email: '', + userId: payload.userId, + redirectPath: '/', + targetApp: 'BONUS', + }); + + res.json({ + ok: true, + startToken: session.startToken, + expiresAt: session.expiresAt.toISOString(), + mode: 'login', + }); +}); + app.post('/auth/telegram-mini-app/session', async (req, res) => { let telegram; try { @@ -409,23 +453,37 @@ app.post('/bot/messenger-login', async (req, res) => { : normalizeMaxProfile(req.body?.profile)), }, }); - const frontendUrl = ( + const mainFrontendUrl = ( process.env.WEB_FRONTEND_URL || process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3000' ).replace(/\/$/, ''); - const nextPath = startSession.redirectPath || ( - channel === 'TELEGRAM' || channel === 'MAX' - ? `/profile/notifications/success?connected=${channel.toLowerCase()}` - : '' - ); - const loginQuery = new URLSearchParams({ - login_token: login.loginToken, - }); - if (nextPath) { - loginQuery.set('next', nextPath); + const bonusFrontendUrl = String( + process.env.BONUS_FRONTEND_URL || + process.env.BONUS_PUBLIC_BASE_URL || + '', + ).trim().replace(/\/$/, ''); + const frontendUrl = startSession.targetApp === 'BONUS' + ? (bonusFrontendUrl || mainFrontendUrl) + : mainFrontendUrl; + + let loginUrl = `${frontendUrl}/login?login_token=${encodeURIComponent(login.loginToken)}`; + if (startSession.targetApp === 'BONUS') { + loginUrl = `${frontendUrl}/?login_token=${encodeURIComponent(login.loginToken)}`; + } else { + const nextPath = startSession.redirectPath || ( + channel === 'TELEGRAM' || channel === 'MAX' + ? `/profile/notifications/success?connected=${channel.toLowerCase()}` + : '' + ); + if (nextPath) { + const loginQuery = new URLSearchParams({ + login_token: login.loginToken, + next: nextPath, + }); + loginUrl = `${frontendUrl}/login?${loginQuery.toString()}`; + } } - const loginUrl = `${frontendUrl}/login?${loginQuery.toString()}`; if (!skipDispatch) { const template = buildMessengerLoginTemplate({