Add standalone bonus program auth flow
This commit is contained in:
58
src/auth.js
58
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();
|
||||
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user