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_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_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 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 AUTH_STATIC_CODE = process.env.AUTH_STATIC_CODE || '123456';
|
||||||
|
|
||||||
const activeChallenges = new Map();
|
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();
|
purgeExpiredMessengerStartSessions();
|
||||||
|
|
||||||
const startToken = crypto.randomBytes(24).toString('base64url');
|
const startToken = crypto.randomBytes(24).toString('base64url');
|
||||||
@@ -189,6 +190,7 @@ export function createMessengerStartSession({ channel, email, userId, redirectPa
|
|||||||
email,
|
email,
|
||||||
userId,
|
userId,
|
||||||
redirectPath,
|
redirectPath,
|
||||||
|
targetApp,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -212,6 +214,7 @@ export function consumeMessengerStartSession(startToken) {
|
|||||||
email: payload.email,
|
email: payload.email,
|
||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
redirectPath: payload.redirectPath,
|
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) {
|
export function consumeTemporaryLoginToken(loginToken) {
|
||||||
purgeExpiredLoginTokens();
|
purgeExpiredLoginTokens();
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,27 @@ export function buildBonusProgramPath(entry = 'bonus-message') {
|
|||||||
return query ? `/bonus-program?${query}` : '/bonus-program';
|
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 }) {
|
export function buildLoginCodeEmailTemplate({ code, expiresAt }) {
|
||||||
const expiresText = new Date(expiresAt).toLocaleString('ru-RU', { hour12: false });
|
const expiresText = new Date(expiresAt).toLocaleString('ru-RU', { hour12: false });
|
||||||
const body = [
|
const body = [
|
||||||
@@ -144,7 +165,7 @@ export function buildBonusCreditTemplate({ amount }) {
|
|||||||
body,
|
body,
|
||||||
message: body.join('\n'),
|
message: body.join('\n'),
|
||||||
buttonText: 'Открыть бонусную программу',
|
buttonText: 'Открыть бонусную программу',
|
||||||
buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('balance')),
|
buttonUrl: buildBonusProgramUrl('balance'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +188,7 @@ export function buildWithdrawalReviewNotificationTemplate({ status, reviewCommen
|
|||||||
body,
|
body,
|
||||||
message: body.join('\n'),
|
message: body.join('\n'),
|
||||||
buttonText: 'Открыть бонусную программу',
|
buttonText: 'Открыть бонусную программу',
|
||||||
buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('withdrawal-review')),
|
buttonUrl: buildBonusProgramUrl('withdrawal-review'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
consumeTemporaryLoginToken,
|
consumeTemporaryLoginToken,
|
||||||
createLoginChallenge,
|
createLoginChallenge,
|
||||||
getStaticAuthCode,
|
getStaticAuthCode,
|
||||||
|
issueBonusProgramLinkToken,
|
||||||
issueAccessToken,
|
issueAccessToken,
|
||||||
maskAuthDestination,
|
maskAuthDestination,
|
||||||
verifyLoginChallengeCode,
|
verifyLoginChallengeCode,
|
||||||
@@ -50,6 +51,30 @@ function latestDate(...values) {
|
|||||||
return new Date(Math.max(...timestamps));
|
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) {
|
function requireUser(context) {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new Error('Authentication required.');
|
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) => {
|
addBonusTransaction: async (_, { input }, context) => {
|
||||||
const manager = requireManagerAccess(context);
|
const manager = requireManagerAccess(context);
|
||||||
await assertManagerCanAccessUser(context.prisma, manager, input.userId);
|
await assertManagerCanAccessUser(context.prisma, manager, input.userId);
|
||||||
|
|||||||
@@ -213,6 +213,13 @@ type IntegrationSyncDashboard {
|
|||||||
items: [IntegrationSyncItem!]!
|
items: [IntegrationSyncItem!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BonusProgramLink {
|
||||||
|
userId: ID!
|
||||||
|
token: String!
|
||||||
|
url: String!
|
||||||
|
expiresAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
type Warehouse {
|
type Warehouse {
|
||||||
id: ID!
|
id: ID!
|
||||||
code: String!
|
code: String!
|
||||||
@@ -563,6 +570,7 @@ type Mutation {
|
|||||||
clientReviewOrder(orderId: ID!, decision: Decision!): Order!
|
clientReviewOrder(orderId: ID!, decision: Decision!): Order!
|
||||||
|
|
||||||
createReferral(input: CreateReferralInput!): ReferralLink!
|
createReferral(input: CreateReferralInput!): ReferralLink!
|
||||||
|
createBonusProgramLink(userId: ID!): BonusProgramLink!
|
||||||
addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction!
|
addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction!
|
||||||
requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest!
|
requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest!
|
||||||
reviewRewardWithdrawal(input: ReviewRewardWithdrawalInput!): RewardWithdrawalRequest!
|
reviewRewardWithdrawal(input: ReviewRewardWithdrawalInput!): RewardWithdrawalRequest!
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
hasMessengerStartSession,
|
hasMessengerStartSession,
|
||||||
issueAccessToken,
|
issueAccessToken,
|
||||||
issueTemporaryLoginToken,
|
issueTemporaryLoginToken,
|
||||||
|
verifyBonusProgramLinkToken,
|
||||||
verifyAccessToken,
|
verifyAccessToken,
|
||||||
} from './auth.js';
|
} from './auth.js';
|
||||||
import { canManagerAccessUser, isManagerRole } from './access.js';
|
import { canManagerAccessUser, isManagerRole } from './access.js';
|
||||||
@@ -103,6 +104,10 @@ function normalizeRedirectPath(value) {
|
|||||||
return redirectPath;
|
return redirectPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTargetApp(value) {
|
||||||
|
return String(value || '').trim().toUpperCase() === 'BONUS' ? 'BONUS' : 'MAIN';
|
||||||
|
}
|
||||||
|
|
||||||
function presentTelegramMiniAppUser(user) {
|
function presentTelegramMiniAppUser(user) {
|
||||||
return presentMiniAppUser(user, 'Пользователь Telegram');
|
return presentMiniAppUser(user, 'Пользователь Telegram');
|
||||||
}
|
}
|
||||||
@@ -160,6 +165,7 @@ app.post('/auth/messenger-start', async (req, res) => {
|
|||||||
const email = authenticatedUser?.email?.trim().toLowerCase() || providedEmail;
|
const email = authenticatedUser?.email?.trim().toLowerCase() || providedEmail;
|
||||||
const userId = authenticatedUser?.id ?? null;
|
const userId = authenticatedUser?.id ?? null;
|
||||||
const redirectPath = normalizeRedirectPath(req.body?.redirectPath);
|
const redirectPath = normalizeRedirectPath(req.body?.redirectPath);
|
||||||
|
const targetApp = normalizeTargetApp(req.body?.targetApp);
|
||||||
|
|
||||||
if (!userId && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
if (!userId && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
res.status(400).json({ error: 'A valid email is required.' });
|
res.status(400).json({ error: 'A valid email is required.' });
|
||||||
@@ -171,6 +177,7 @@ app.post('/auth/messenger-start', async (req, res) => {
|
|||||||
email,
|
email,
|
||||||
userId,
|
userId,
|
||||||
redirectPath,
|
redirectPath,
|
||||||
|
targetApp,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
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) => {
|
app.post('/auth/telegram-mini-app/session', async (req, res) => {
|
||||||
let telegram;
|
let telegram;
|
||||||
try {
|
try {
|
||||||
@@ -409,23 +453,37 @@ app.post('/bot/messenger-login', async (req, res) => {
|
|||||||
: normalizeMaxProfile(req.body?.profile)),
|
: normalizeMaxProfile(req.body?.profile)),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const frontendUrl = (
|
const mainFrontendUrl = (
|
||||||
process.env.WEB_FRONTEND_URL ||
|
process.env.WEB_FRONTEND_URL ||
|
||||||
process.env.NUXT_PUBLIC_SITE_URL ||
|
process.env.NUXT_PUBLIC_SITE_URL ||
|
||||||
'http://localhost:3000'
|
'http://localhost:3000'
|
||||||
).replace(/\/$/, '');
|
).replace(/\/$/, '');
|
||||||
|
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 || (
|
const nextPath = startSession.redirectPath || (
|
||||||
channel === 'TELEGRAM' || channel === 'MAX'
|
channel === 'TELEGRAM' || channel === 'MAX'
|
||||||
? `/profile/notifications/success?connected=${channel.toLowerCase()}`
|
? `/profile/notifications/success?connected=${channel.toLowerCase()}`
|
||||||
: ''
|
: ''
|
||||||
);
|
);
|
||||||
|
if (nextPath) {
|
||||||
const loginQuery = new URLSearchParams({
|
const loginQuery = new URLSearchParams({
|
||||||
login_token: login.loginToken,
|
login_token: login.loginToken,
|
||||||
|
next: nextPath,
|
||||||
});
|
});
|
||||||
if (nextPath) {
|
loginUrl = `${frontendUrl}/login?${loginQuery.toString()}`;
|
||||||
loginQuery.set('next', nextPath);
|
}
|
||||||
}
|
}
|
||||||
const loginUrl = `${frontendUrl}/login?${loginQuery.toString()}`;
|
|
||||||
|
|
||||||
if (!skipDispatch) {
|
if (!skipDispatch) {
|
||||||
const template = buildMessengerLoginTemplate({
|
const template = buildMessengerLoginTemplate({
|
||||||
|
|||||||
Reference in New Issue
Block a user