Implement messenger login tokens and bot login endpoint
This commit is contained in:
41
src/auth.js
41
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_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_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_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 AUTH_STATIC_CODE = process.env.AUTH_STATIC_CODE || '123456';
|
||||||
|
|
||||||
const activeChallenges = new Map();
|
const activeChallenges = new Map();
|
||||||
|
const activeLoginTokens = new Map();
|
||||||
|
|
||||||
function sign(data) {
|
function sign(data) {
|
||||||
return crypto.createHmac('sha256', AUTH_TOKEN_SECRET).update(data).digest('base64url');
|
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) {
|
function maskEmail(email) {
|
||||||
const [local, domain] = email.split('@');
|
const [local, domain] = email.split('@');
|
||||||
if (!domain) {
|
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() {
|
export function getStaticAuthCode() {
|
||||||
return AUTH_STATIC_CODE;
|
return AUTH_STATIC_CODE;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ function maskChannel(channelId) {
|
|||||||
return `${text.slice(0, 3)}***${text.slice(-3)}`;
|
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;
|
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return {
|
return {
|
||||||
@@ -23,6 +23,18 @@ async function sendTelegramMessage(channelId, message) {
|
|||||||
chat_id: channelId,
|
chat_id: channelId,
|
||||||
text: message,
|
text: message,
|
||||||
disable_web_page_preview: true,
|
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;
|
const webhookUrl = process.env.MAX_BOT_WEBHOOK_URL;
|
||||||
if (!webhookUrl) {
|
if (!webhookUrl) {
|
||||||
return {
|
return {
|
||||||
@@ -63,6 +75,14 @@ async function sendMaxMessage(channelId, message) {
|
|||||||
channelId,
|
channelId,
|
||||||
text: message,
|
text: message,
|
||||||
source: 'fregat-apollo-backend',
|
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') {
|
if (type === 'TELEGRAM') {
|
||||||
return sendTelegramMessage(channelId, message);
|
return sendTelegramMessage(channelId, message, options);
|
||||||
}
|
}
|
||||||
if (type === 'MAX') {
|
if (type === 'MAX') {
|
||||||
return sendMaxMessage(channelId, message);
|
return sendMaxMessage(channelId, message, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
consumeTemporaryLoginToken,
|
||||||
createLoginChallenge,
|
createLoginChallenge,
|
||||||
getStaticAuthCode,
|
getStaticAuthCode,
|
||||||
issueAccessToken,
|
issueAccessToken,
|
||||||
@@ -257,15 +258,16 @@ export const resolvers = {
|
|||||||
|
|
||||||
Mutation: {
|
Mutation: {
|
||||||
requestLoginCode: async (_, { input }, context) => {
|
requestLoginCode: async (_, { input }, context) => {
|
||||||
|
if (input.channel !== 'EMAIL') {
|
||||||
|
throw new Error('Code login is supported only for EMAIL channel.');
|
||||||
|
}
|
||||||
|
|
||||||
const destination = input.destination.trim();
|
const destination = input.destination.trim();
|
||||||
if (!destination) {
|
if (!destination) {
|
||||||
throw new Error('Destination is required.');
|
throw new Error('Destination is required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
let user = null;
|
const user = await context.prisma.user.findFirst({
|
||||||
|
|
||||||
if (input.channel === 'EMAIL') {
|
|
||||||
user = await context.prisma.user.findFirst({
|
|
||||||
where: {
|
where: {
|
||||||
email: {
|
email: {
|
||||||
equals: destination,
|
equals: destination,
|
||||||
@@ -273,18 +275,6 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} 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) {
|
if (!user) {
|
||||||
throw new Error('User for this destination was not found.');
|
throw new Error('User for this destination was not found.');
|
||||||
@@ -297,20 +287,7 @@ export const resolvers = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const code = getStaticAuthCode();
|
const code = getStaticAuthCode();
|
||||||
const authMessage = `Код входа в Fregat: ${code}`;
|
|
||||||
|
|
||||||
if (input.channel === 'EMAIL') {
|
|
||||||
console.info(`[auth] login code for ${destination}: ${code}`);
|
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 {
|
return {
|
||||||
challengeToken: challenge.challengeToken,
|
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) =>
|
registerSelf: (_, { input }, context) =>
|
||||||
context.prisma.registrationRequest.create({
|
context.prisma.registrationRequest.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -323,6 +323,7 @@ input ReviewRewardWithdrawalInput {
|
|||||||
type Mutation {
|
type Mutation {
|
||||||
requestLoginCode(input: RequestLoginCodeInput!): AuthCodeRequestResult!
|
requestLoginCode(input: RequestLoginCodeInput!): AuthCodeRequestResult!
|
||||||
verifyLoginCode(input: VerifyLoginCodeInput!): AuthSession!
|
verifyLoginCode(input: VerifyLoginCodeInput!): AuthSession!
|
||||||
|
consumeLoginToken(token: String!): AuthSession!
|
||||||
registerSelf(input: RegisterSelfInput!): RegistrationRequest!
|
registerSelf(input: RegisterSelfInput!): RegistrationRequest!
|
||||||
reviewRegistrationRequest(input: ReviewRegistrationRequestInput!): RegistrationRequest!
|
reviewRegistrationRequest(input: ReviewRegistrationRequestInput!): RegistrationRequest!
|
||||||
createInvitation(input: CreateInvitationInput!): Invitation!
|
createInvitation(input: CreateInvitationInput!): Invitation!
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import express from 'express';
|
|||||||
import { ApolloServer } from '@apollo/server';
|
import { ApolloServer } from '@apollo/server';
|
||||||
import { expressMiddleware } from '@as-integrations/express5';
|
import { expressMiddleware } from '@as-integrations/express5';
|
||||||
|
|
||||||
|
import { issueTemporaryLoginToken } from './auth.js';
|
||||||
import { buildContext } from './context.js';
|
import { buildContext } from './context.js';
|
||||||
|
import { sendMessengerMessage } from './messenger.js';
|
||||||
import { prisma } from './prisma-client.js';
|
import { prisma } from './prisma-client.js';
|
||||||
import { resolvers } from './resolvers.js';
|
import { resolvers } from './resolvers.js';
|
||||||
|
|
||||||
@@ -29,6 +31,78 @@ app.get('/healthz', (_, res) => {
|
|||||||
res.json({ status: 'ok' });
|
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(
|
app.use(
|
||||||
'/graphql',
|
'/graphql',
|
||||||
expressMiddleware(server, {
|
expressMiddleware(server, {
|
||||||
|
|||||||
Reference in New Issue
Block a user