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_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_LINK_TTL_SECONDS = Number(process.env.AUTH_LOGIN_LINK_TTL_SECONDS ?? 10 * 60);
|
||||
const AUTH_STATIC_CODE = process.env.AUTH_STATIC_CODE || '123456';
|
||||
|
||||
const activeChallenges = new Map();
|
||||
const activeLoginTokens = new Map();
|
||||
|
||||
function sign(data) {
|
||||
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) {
|
||||
const [local, domain] = email.split('@');
|
||||
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() {
|
||||
return AUTH_STATIC_CODE;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ function maskChannel(channelId) {
|
||||
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;
|
||||
if (!token) {
|
||||
return {
|
||||
@@ -23,6 +23,18 @@ async function sendTelegramMessage(channelId, message) {
|
||||
chat_id: channelId,
|
||||
text: message,
|
||||
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;
|
||||
if (!webhookUrl) {
|
||||
return {
|
||||
@@ -63,6 +75,14 @@ async function sendMaxMessage(channelId, message) {
|
||||
channelId,
|
||||
text: message,
|
||||
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') {
|
||||
return sendTelegramMessage(channelId, message);
|
||||
return sendTelegramMessage(channelId, message, options);
|
||||
}
|
||||
if (type === 'MAX') {
|
||||
return sendMaxMessage(channelId, message);
|
||||
return sendMaxMessage(channelId, message, options);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import {
|
||||
consumeTemporaryLoginToken,
|
||||
createLoginChallenge,
|
||||
getStaticAuthCode,
|
||||
issueAccessToken,
|
||||
@@ -257,15 +258,16 @@ export const resolvers = {
|
||||
|
||||
Mutation: {
|
||||
requestLoginCode: async (_, { input }, context) => {
|
||||
if (input.channel !== 'EMAIL') {
|
||||
throw new Error('Code login is supported only for EMAIL channel.');
|
||||
}
|
||||
|
||||
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({
|
||||
const user = await context.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
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) {
|
||||
throw new Error('User for this destination was not found.');
|
||||
@@ -297,20 +287,7 @@ export const resolvers = {
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -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) =>
|
||||
context.prisma.registrationRequest.create({
|
||||
data: {
|
||||
|
||||
@@ -323,6 +323,7 @@ input ReviewRewardWithdrawalInput {
|
||||
type Mutation {
|
||||
requestLoginCode(input: RequestLoginCodeInput!): AuthCodeRequestResult!
|
||||
verifyLoginCode(input: VerifyLoginCodeInput!): AuthSession!
|
||||
consumeLoginToken(token: String!): AuthSession!
|
||||
registerSelf(input: RegisterSelfInput!): RegistrationRequest!
|
||||
reviewRegistrationRequest(input: ReviewRegistrationRequestInput!): RegistrationRequest!
|
||||
createInvitation(input: CreateInvitationInput!): Invitation!
|
||||
|
||||
@@ -8,7 +8,9 @@ import express from 'express';
|
||||
import { ApolloServer } from '@apollo/server';
|
||||
import { expressMiddleware } from '@as-integrations/express5';
|
||||
|
||||
import { issueTemporaryLoginToken } from './auth.js';
|
||||
import { buildContext } from './context.js';
|
||||
import { sendMessengerMessage } from './messenger.js';
|
||||
import { prisma } from './prisma-client.js';
|
||||
import { resolvers } from './resolvers.js';
|
||||
|
||||
@@ -29,6 +31,78 @@ app.get('/healthz', (_, res) => {
|
||||
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(
|
||||
'/graphql',
|
||||
expressMiddleware(server, {
|
||||
|
||||
Reference in New Issue
Block a user